Listening to Form Changes 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 aFormGroup
(orFormControl
) - 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 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 an Observable’s 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 lines 92 and 93, 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, Subject } = rxjs;
const { takeUntil } = rxjs.operators;
@Injectable({
providedIn: 'root'
})
class ObsService {
constructor() {}
getObs(): Observable<number> {
return new Observable<number>(observer => {
console.info('New subscription created');
let count = 0;
const interval = setInterval(() => {
count++;
console.info("incrementing", count);
observer.next(count);
}, 1000);
return () => {
clearIntervalue(interval);
console.info('unsubscribed');
}
})
}
}
@Component({
selector: 'my-app',
template: `
<destroy-component *ngIf="showComponent"></destroy-component>
<button (click)="removeComponent()" *ngIf="showComponent">
Remove component
</button>
<p *ngIf="!showComponent">The component has been removed.</p>
`
})
class AppComponent {
showComponent: boolean = true;
constructor() {}
removeComponent(): void {
this.showComponent = false;
}
}
@Component({
selector: 'destroy-component',
template: `
<p>This component will be destroyed. 🪄</p>
<button (click)="startObservable()">Start observable</button>
<p>{{ myDisplayValue }}</p>
`
})
class DestroyComponent implements OnDestroy {
private onDestroy$ = new Subject<void>();
myDisplayValue: string;
constructor(private obsService: ObsService) {}
startObservable(): void {
// Subscribing to a service that emits observable values every second.
this.obsService.getObs()
.pipe(takeUntil(this.onDestroy$))
.subscribe((value) => {
console.info('new value', value);
this.myDisplayValue = value;
});
}
ngOnDestroy(): void {
console.info('destroying component');
this.onDestroy$.next();
this.onDestroy$.complete();
}
}
//THIS IS A HACK JUST FOR CODEPEN TO WORK
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 when the component is destroyed .
<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 { 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>
<ng-container *ngIf="myObservable$ | async as latestObservableValue">
{{ latestObservableValue }}
</ng-container>
`
})
class HomeComponent implements OnInit {
myObservable$: Observable<string>;
constructor() {}
ngOnInit(): void {
this.myObservable$ = new Observable(observer => {
setTimeout(() => {
observer.next('hello observable');
}, 1000);
setTimeout(() => {
observer.next('how are you');
}, 2000);
setTimeout(() => {
observer.complete();
}, 3000);
});
}
}
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,
],
bootstrap: [AppComponent],
providers: []
})
class AppModule {}
const { platformBrowserDynamic } = ng.platformBrowserDynamic;
platformBrowserDynamic()
.bootstrapModule(AppModule)
.catch(err => console.error(err));
</script>
This example shows how to unsubscribe from multiple Observables.
<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, Subject } = rxjs;
const { takeUntil } = rxjs.operators;
@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, OnDestroy {
private onDestroy$ = new Subject<void>();
myObservable$: Observable<string>;
myOtherObservable$: Observable<string>;
myDisplayValue: string;
myOtherDisplayValue: string;
constructor(private fb: FormBuilder) {}
ngOnInit(): void {
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');
}, 9500);
setTimeout(() => {
observer.complete();
}, 3500);
});
this.myObservable$
.pipe(takeUntil(this.onDestroy$))
.subscribe((value) => {
this.myDisplayValue = value;
});
this.myOtherObservable$
.pipe(takeUntil(this.onDestroy$))
.subscribe((value) => {
this.myOtherDisplayValue = value;
});
}
ngOnDestroy(): void {
this.onDestroy$.next();
this.onDestroy$.complete();
}
}
//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>
Handling errors
When an Observable
encounters an error, it emits an error notification and terminates the stream. Proper error handling ensures your application can manage and respond to errors gracefully.
You can handle errors directly in the subscription by providing an error handling function. This function is called if the Observable
emits an error.
Here’s an example:
of([1, 2, 3]).subscribe({
next: (v) => console.info(v),
error: (e) => console.error(e),
complete: () => console.info('complete')
});
In this pattern, the subscription object includes next for handling emitted values, error for handling errors, and complete for handling the completion of the Observable
stream.
The catchError
operator is a more versatile tool for error handling. It allows you to intercept an error and return a new Observable
or rethrow an error. This can be useful for implementing fallback values or more complex error handling strategies.
of([1, 2, 31]).pipe(
catchError(() => {
return of([]);
})
).subscribe({
next: returnedObservable => {
console.info(returnedObservable);
},
error: console.error
});
In the case above, catchError is used to intercept an error and return an empty array as a fallback, ensuring that the Observable
stream does not terminate on error.
Sometimes, you might want to perform some operation on the error and then rethrow it. This can be done within catchError
.
of([1, 2, 31]).pipe(
catchError(error => {
throw `Caught error: ${error}`;
})
).subscribe({
next: console.info,
error: thrownError => {
console.error(thrownError);
}
});
Here, catchError
catches the error, modifies it, and then rethrows it. The rethrown error is then handled in the error callback of the subscription.
Listening to form changes
We can listen to changes to values on FormControl
s 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).
<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]="userForm">
<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 {
userForm: FormGroup;
constructor(private fb:FormBuilder) {}
ngOnInit(): void {
this.userForm = this.fb.group({
firstName: {value: '', disabled: false},
lastName: {value: '', disabled: false},
email: {value: '', disabled: false}
});
this.userForm.valueChanges.subscribe((value) => {
console.info(value);
});
}
}
//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
- Subscribe to the
state
andcity
formControl value changes and log the resulting value to the console. - Unsubscribe from Subscriptions within
restaurant.component
in thengOnDestroy
function
P1: Setup
✏️ Update src/app/restaurant/restaurant.component.ts to be:
import { Component, OnDestroy, OnInit } from '@angular/core';
import { FormBuilder, FormControl, FormGroup } from '@angular/forms';
import { Subject, takeUntil } from 'rxjs';
import { Restaurant } from './restaurant';
import { ResponseData, RestaurantService } from './restaurant.service';
export interface Data {
value: Restaurant[];
isPending: boolean;
}
@Component({
selector: 'pmo-restaurant',
templateUrl: './restaurant.component.html',
styleUrl: './restaurant.component.css',
})
export class RestaurantComponent implements OnInit {
form: FormGroup<{
state: FormControl<string>;
city: FormControl<string>;
}> = this.createForm();
restaurants: Data = {
value: [],
isPending: false,
};
states = {
isPending: false,
value: [
{ name: 'Illinois', short: 'IL' },
{ name: 'Wisconsin', short: 'WI' },
],
};
cities = {
isPending: false,
value: [{ name: 'Springfield' }, { name: 'Madison' }],
};
constructor(
private restaurantService: RestaurantService,
private fb: FormBuilder
) {}
ngOnInit(): void {
this.restaurants.isPending = true;
this.restaurantService.getRestaurants().subscribe((res: ResponseData) => {
this.restaurants.value = res.data;
this.restaurants.isPending = false;
});
}
createForm(): FormGroup<{
state: FormControl<string>;
city: FormControl<string>;
}> {
return this.fb.nonNullable.group({
state: { value: '', disabled: false },
city: { value: '', disabled: false },
});
}
}
P1: How 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
Click to see the solution
✏️ Update src/app/restaurant/restaurant.component.tsimport { Component, OnDestroy, OnInit } from '@angular/core';
import { FormBuilder, FormControl, FormGroup } from '@angular/forms';
import { Subject, takeUntil } from 'rxjs';
import { Restaurant } from './restaurant';
import { ResponseData, RestaurantService } from './restaurant.service';
export interface Data {
value: Restaurant[];
isPending: boolean;
}
@Component({
selector: 'pmo-restaurant',
templateUrl: './restaurant.component.html',
styleUrl: './restaurant.component.css',
})
export class RestaurantComponent implements OnInit, OnDestroy {
form: FormGroup<{
state: FormControl<string>;
city: FormControl<string>;
}> = this.createForm();
restaurants: Data = {
value: [],
isPending: false,
};
states = {
isPending: false,
value: [
{ name: 'Illinois', short: 'IL' },
{ name: 'Wisconsin', short: 'WI' },
],
};
cities = {
isPending: false,
value: [{ name: 'Springfield' }, { name: 'Madison' }],
};
private onDestroy$ = new Subject<void>();
constructor(
private restaurantService: RestaurantService,
private fb: FormBuilder
) {}
ngOnInit(): void {
this.restaurants.isPending = true;
this.restaurantService
.getRestaurants()
.pipe(takeUntil(this.onDestroy$))
.subscribe((res: ResponseData) => {
this.restaurants.value = res.data;
this.restaurants.isPending = false;
});
this.form.controls.state.valueChanges
.pipe(takeUntil(this.onDestroy$))
.subscribe((value) => {
console.info('state', value);
});
this.form.controls.city.valueChanges
.pipe(takeUntil(this.onDestroy$))
.subscribe((value) => {
console.info('city', value);
});
}
ngOnDestroy(): void {
this.onDestroy$.next();
this.onDestroy$.complete();
}
createForm(): FormGroup<{
state: FormControl<string>;
city: FormControl<string>;
}> {
return this.fb.nonNullable.group({
state: { value: '', disabled: false },
city: { value: '', disabled: false },
});
}
}
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 a 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 Angular’s 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()
.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{
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 '/states'
Method 2 - getCities
, takes a string param called state
and makes a request to '/cities?state="{state abbreviation here}"'
P2: Setup
✏️ Update src/app/restaurant/restaurant.service.ts
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
import { Restaurant } from './restaurant';
export interface ResponseData {
data: Restaurant[];
}
@Injectable({
providedIn: 'root'
})
export class RestaurantService {
constructor(private httpClient: HttpClient) {}
getRestaurants(): Observable<ResponseData> {
return this.httpClient.get<ResponseData>(
environment.apiUrl + '/restaurants'
);
}
}
P2: How to verify your solution is correct
✏️ Update the spec file src/app/restaurant/restaurant.service.spec.ts to be:
import {
HttpClientTestingModule,
HttpTestingController,
} from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { Restaurant } from './restaurant';
import { ResponseData, RestaurantService } from './restaurant.service';
describe('RestaurantService', () => {
let httpTestingController: HttpTestingController;
let service: RestaurantService;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
});
httpTestingController = TestBed.inject(HttpTestingController);
service = TestBed.inject(RestaurantService);
});
it('should be created', () => {
expect(service).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',
},
],
};
service.getRestaurants().subscribe((restaurants: ResponseData) => {
expect(restaurants).toEqual(mockRestaurants);
});
const url = 'http://localhost:7070/restaurants';
const req = httpTestingController.expectOne(url);
expect(req.request.method).toEqual('GET');
req.flush(mockRestaurants);
httpTestingController.verify();
});
it('can set proper properties on restaurant type', () => {
const 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' }],
};
service.getStates().subscribe((states) => {
expect(states).toEqual(mockStates);
});
const url = 'http://localhost:7070/states';
const req = httpTestingController.expectOne(url);
expect(req.request.method).toEqual('GET');
req.flush(mockStates);
httpTestingController.verify();
});
it('should make a GET request to cities', () => {
const mockCities = {
data: [{ name: 'Kansas City', state: 'MO' }],
};
service.getCities('MO').subscribe((cities) => {
expect(cities).toEqual(mockCities);
});
const url = 'http://localhost:7070/cities?state=MO';
const req = httpTestingController.expectOne(url);
expect(req.request.method).toEqual('GET');
req.flush(mockCities);
httpTestingController.verify();
});
});
P2: Solution
If you’ve implemented the solution correctly, the tests will pass when you run npm run test
!
Click to see the solution
✏️ Update src/app/restaurant/restaurant.service.tsimport { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
import { Restaurant } from './restaurant';
export interface ResponseData {
data: Restaurant[];
}
@Injectable({
providedIn: 'root'
})
export class RestaurantService {
constructor(private httpClient: HttpClient) {}
getRestaurants(): Observable<ResponseData> {
return this.httpClient.get<ResponseData>(
environment.apiUrl + '/restaurants'
);
}
getStates(): Observable<any> {
return this.httpClient.get<any>(environment.apiUrl + '/states');
}
getCities(state: string): Observable<any> {
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 an 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 {
myListOfStrings = new GenericCollection<string>();
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 to 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 { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
import { Restaurant } from './restaurant';
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(): Observable<ResponseData> {
return this.httpClient.get<ResponseData>(
environment.apiUrl + '/restaurants'
);
}
getStates(): Observable<any> {
return this.httpClient.get<any>(environment.apiUrl + '/states');
}
getCities(state: string): Observable<any> {
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 {
HttpClientTestingModule,
HttpTestingController,
} from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { Restaurant } from './restaurant';
import {
City,
ResponseData,
RestaurantService,
State,
} from './restaurant.service';
describe('RestaurantService', () => {
let httpTestingController: HttpTestingController;
let service: RestaurantService;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
});
httpTestingController = TestBed.inject(HttpTestingController);
service = TestBed.inject(RestaurantService);
});
it('should be created', () => {
expect(service).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',
},
],
};
service
.getRestaurants()
.subscribe((restaurants: ResponseData<Restaurant>) => {
expect(restaurants).toEqual(mockRestaurants);
});
const url = 'http://localhost:7070/restaurants';
const req = httpTestingController.expectOne(url);
expect(req.request.method).toEqual('GET');
req.flush(mockRestaurants);
httpTestingController.verify();
});
it('can set proper properties on restaurant type', () => {
const 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' }],
};
service.getStates().subscribe((states: ResponseData<State>) => {
expect(states).toEqual(mockStates);
});
const url = 'http://localhost:7070/states';
const req = httpTestingController.expectOne(url);
expect(req.request.method).toEqual('GET');
req.flush(mockStates);
httpTestingController.verify();
});
it('should make a GET request to cities', () => {
const mockCities = {
data: [{ name: 'Kansas City', state: 'MO' }],
};
service.getCities('MO').subscribe((cities: ResponseData<City>) => {
expect(cities).toEqual(mockCities);
});
const url = 'http://localhost:7070/cities?state=MO';
const req = httpTestingController.expectOne(url);
expect(req.request.method).toEqual('GET');
req.flush(mockCities);
httpTestingController.verify();
});
});
P3: Solution
Click to see the solution
✏️ Update src/app/restaurant/restaurant.service.tsimport { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
import { Restaurant } from './restaurant';
export interface ResponseData<DataType> {
data: 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(): Observable<ResponseData<Restaurant>> {
return this.httpClient.get<ResponseData<Restaurant>>(
environment.apiUrl + '/restaurants'
);
}
getStates(): Observable<ResponseData<State>> {
return this.httpClient.get<ResponseData<State>>(
environment.apiUrl + '/states'
);
}
getCities(state: string): Observable<ResponseData<City>> {
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, OnDestroy, OnInit } from '@angular/core';
import { FormBuilder, FormControl, FormGroup } from '@angular/forms';
import { Subject, takeUntil } from 'rxjs';
import { Restaurant } from './restaurant';
import { ResponseData, RestaurantService } from './restaurant.service';
export interface Data {
value: Restaurant[];
isPending: boolean;
}
@Component({
selector: 'pmo-restaurant',
templateUrl: './restaurant.component.html',
styleUrl: './restaurant.component.css',
})
export class RestaurantComponent implements OnInit, OnDestroy {
form: FormGroup<{
state: FormControl<string>;
city: FormControl<string>;
}> = this.createForm();
restaurants: Data = {
value: [],
isPending: false,
};
states = {
isPending: false,
value: [
{ name: 'Illinois', short: 'IL' },
{ name: 'Wisconsin', short: 'WI' },
],
};
cities = {
isPending: false,
value: [{ name: 'Springfield' }, { name: 'Madison' }],
};
private onDestroy$ = new Subject<void>();
constructor(
private restaurantService: RestaurantService,
private fb: FormBuilder
) {}
ngOnInit(): void {
this.restaurants.isPending = true;
this.restaurantService
.getRestaurants()
.pipe(takeUntil(this.onDestroy$))
.subscribe((res: ResponseData<Restaurant>) => {
this.restaurants.value = res.data;
this.restaurants.isPending = false;
});
this.form.controls.state.valueChanges
.pipe(takeUntil(this.onDestroy$))
.subscribe((value) => {
console.info('state', value);
});
this.form.controls.city.valueChanges
.pipe(takeUntil(this.onDestroy$))
.subscribe((value) => {
console.info('city', value);
});
}
ngOnDestroy(): void {
this.onDestroy$.next();
this.onDestroy$.complete();
}
createForm(): FormGroup<{
state: FormControl<string>;
city: FormControl<string>;
}> {
return this.fb.nonNullable.group({
state: { value: '', disabled: false },
city: { value: '', disabled: false },
});
}
}
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: What you need to know
- How to call service methods in a component
- How to write generics
Call methods on FormControls
The ReactiveForms API makes it easy for us to change our FormControl
s 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]="userForm">
<label>
First name:
<input type="text" formControlName="firstName">
<p>
This control is enabled:
{{userForm.controls.firstName.enabled}}
</p>
<button (click)="toggleControl(userForm.controls.firstName)">
Toggle firstName control
</button>
<br />
</label>
<label>
Last name:
<input type="text" formControlName="lastName">
<p>
This control is enabled:
{{userForm.controls.lastName.enabled}}
</p>
<button (click)="toggleControl(userForm.controls.lastName)">
Toggle lastName control
</button>
<br />
</label>
<label>
Email:
<input type="text" formControlName="email">
<p>
This control is enabled:
{{userForm.controls.email.enabled}}
</p>
<button (click)="toggleControl(userForm.controls.email)">
Toggle email control
</button>
<br />
</label>
</form>
`
})
class AppComponent {
userForm: FormGroup;
constructor(private fb:FormBuilder) {}
ngOnInit() {
this.userForm = this.fb.group({
firstName: {value: '', disabled: false},
lastName: {value: '', disabled: false},
email: {value: '', disabled: false}
});
this.userForm.valueChanges.subscribe((value) => {
console.info(value);
});
}
toggleControl(control) {
let formControl = control as FormControl;
if(formControl.enabled) {
formControl.disable();
}
else {
formControl.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>
P4: Technical requirements
- Rewrite the
Data
interface to be a generic to work with State and City types as well - Mark state and city dropdowns as disabled until they are populated with data
- Fetch the states list when the component first loads (
ngOnInit
) and populate the dropdown options with the values - When the State
FormControl
value changes, fetch the list of cities for the newly selected state and reset the list of restaurants to an empty array - 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 {
ComponentFixture,
fakeAsync,
TestBed,
tick,
} from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { RouterTestingModule } from '@angular/router/testing';
import { of } from 'rxjs';
import { ImageUrlPipe } from '../image-url.pipe';
import { RestaurantComponent } from './restaurant.component';
import { RestaurantService } from './restaurant.service';
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 fixture: ComponentFixture<RestaurantComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [RouterTestingModule, ReactiveFormsModule],
declarations: [RestaurantComponent, ImageUrlPipe],
providers: [
{ provide: RestaurantService, useClass: MockRestaurantService },
],
}).compileComponents();
fixture = TestBed.createComponent(RestaurantComponent);
});
it('should create', () => {
const component: RestaurantComponent = fixture.componentInstance;
expect(component).toBeTruthy();
});
it('should render title in a h2 tag', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h2')?.textContent).toContain('Restaurants');
});
it('should not show any restaurants markup if no restaurants', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.restaurant')).toBe(null);
});
it('should have two .restaurant divs', fakeAsync((): void => {
fixture.detectChanges();
tick(501);
fixture.componentInstance.form.get('state')?.patchValue('CA');
fixture.componentInstance.form.get('city')?.patchValue('Sacramento');
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const restaurantDivs = compiled.getElementsByClassName('restaurant');
const hoursDivs = compiled.getElementsByClassName('hours-price');
expect(restaurantDivs.length).toEqual(2);
expect(hoursDivs.length).toEqual(2);
}));
it('should display restaurant information', fakeAsync((): void => {
fixture.detectChanges();
tick(501);
fixture.componentInstance.form.get('state')?.patchValue('CA');
fixture.componentInstance.form.get('city')?.patchValue('Sacramento');
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
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', 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();
const 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', () => {
fixture.detectChanges();
fixture.componentInstance.restaurants.isPending = true;
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const loadingDiv = compiled.querySelector('.loading');
expect(loadingDiv).toBeTruthy();
});
it('should not show a loading div if isPending is false', () => {
fixture.detectChanges();
fixture.componentInstance.restaurants.isPending = false;
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const loadingDiv = compiled.querySelector('.loading');
expect(loadingDiv).toBe(null);
});
it('should have a form property with city and state keys', () => {
fixture.detectChanges();
expect(fixture.componentInstance.form.controls['state']).toBeTruthy();
expect(fixture.componentInstance.form.controls['city']).toBeTruthy();
});
it('should show a state dropdown', () => {
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const stateSelect = compiled.querySelector(
'select[formcontrolname="state"]'
);
expect(stateSelect).toBeTruthy();
});
it('should show a city dropdown', () => {
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const citySelect = compiled.querySelector('select[formcontrolname="city"]');
expect(citySelect).toBeTruthy();
});
it('should set states value to states response data and set isPending to false', fakeAsync((): void => {
fixture.detectChanges();
tick();
fixture.detectChanges();
const 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', fakeAsync((): void => {
fixture.detectChanges();
tick();
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const stateOption = compiled.querySelector(
'select[formcontrolname="state"] option:nth-child(2)'
) as HTMLInputElement;
expect(stateOption.textContent?.trim()).toEqual('Missouri');
expect(stateOption.value).toEqual('MO');
}));
it('should set cities value to cities response data and set isPending to false', fakeAsync((): void => {
fixture.detectChanges();
tick();
fixture.componentInstance.form.get('state')?.patchValue('CA');
fixture.detectChanges();
const 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', fakeAsync((): void => {
fixture.detectChanges();
tick();
fixture.componentInstance.form.get('state')?.patchValue('CA');
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const cityOption = compiled.querySelector(
'select[formcontrolname="city"] option:nth-child(2)'
) as HTMLInputElement;
expect(cityOption.textContent?.trim()).toEqual('Sacramento');
expect(cityOption.value).toEqual('Sacramento');
}));
it('state dropdown should be disabled until states are populated', fakeAsync((): void => {
const storeGetStatesFunc = fixture.componentInstance.getStates;
fixture.componentInstance.getStates = () => {}; // preventing getStates func from being called
fixture.detectChanges(); // detecting changes for createForm func to be called
const 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();
const stateFormControl2 = fixture.componentInstance.form.get('state');
expect(stateFormControl2?.enabled).toBe(true);
}));
it('city dropdown should be disabled until cities are populated', fakeAsync((): void => {
fixture.detectChanges(); // detecting changes for createForm func to be called
const cityFormControl1 = fixture.componentInstance.form.get('city');
expect(cityFormControl1?.enabled).toBe(false);
fixture.componentInstance.form.get('state')?.patchValue('CA');
fixture.detectChanges();
const cityFormControl2 = fixture.componentInstance.form.get('city');
expect(cityFormControl2?.enabled).toBe(true);
}));
it('should reset list of restaurants when new state is selected', fakeAsync((): void => {
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: Solution
Click to see the solution
✏️ Update src/app/restaurant/restaurant.component.tsimport { Component, OnDestroy, OnInit } from '@angular/core';
import { FormBuilder, FormControl, FormGroup } from '@angular/forms';
import { Subject, takeUntil } from 'rxjs';
import { Restaurant } from './restaurant';
import {
City,
ResponseData,
RestaurantService,
State,
} from './restaurant.service';
export interface Data<T> {
value: T[];
isPending: boolean;
}
@Component({
selector: 'pmo-restaurant',
templateUrl: './restaurant.component.html',
styleUrl: './restaurant.component.css',
})
export class RestaurantComponent implements OnInit, OnDestroy {
form: FormGroup<{
state: FormControl<string>;
city: FormControl<string>;
}> = this.createForm();
restaurants: Data<Restaurant> = {
value: [],
isPending: false,
};
states: Data<State> = {
isPending: false,
value: [],
};
cities: Data<City> = {
isPending: false,
value: [],
};
private onDestroy$ = new Subject<void>();
constructor(
private restaurantService: RestaurantService,
private fb: FormBuilder
) {}
ngOnInit(): void {
this.getStates();
this.onChanges();
}
ngOnDestroy(): void {
this.onDestroy$.next();
this.onDestroy$.complete();
}
createForm(): FormGroup<{
state: FormControl<string>;
city: FormControl<string>;
}> {
return this.fb.nonNullable.group({
state: { value: '', disabled: true },
city: { value: '', disabled: true },
});
}
onChanges(): void {
let state: string = this.form.controls.state.value;
this.form.controls.state.valueChanges
.pipe(takeUntil(this.onDestroy$))
.subscribe((value) => {
this.restaurants.value = [];
if (value) {
// only enable city if state has value
this.form.controls.city.enable({
emitEvent: false,
});
// if state has a value and has changed, clear previous city value
if (state !== value) {
this.form.controls.city.setValue('');
}
// fetch cities based on state value
this.getCities(value);
} else {
// disable city if no value
this.form.controls.city.disable({
emitEvent: false,
});
}
state = value;
});
this.form.controls.city.valueChanges
.pipe(takeUntil(this.onDestroy$))
.subscribe((value) => {
if (value) {
this.getRestaurants();
}
});
}
getStates(): void {
this.states.isPending = true;
this.restaurantService
.getStates()
.pipe(takeUntil(this.onDestroy$))
.subscribe((res: ResponseData<State>) => {
this.states.value = res.data;
this.states.isPending = false;
this.form.controls.state.enable();
});
}
getCities(state: string): void {
this.cities.isPending = true;
this.restaurantService
.getCities(state)
.pipe(takeUntil(this.onDestroy$))
.subscribe((res: ResponseData<City>) => {
this.cities.value = res.data;
this.cities.isPending = false;
this.form.controls.city.enable({
emitEvent: false,
});
});
}
getRestaurants(): void {
this.restaurants.isPending = true;
this.restaurantService
.getRestaurants()
.pipe(takeUntil(this.onDestroy$))
.subscribe((res: ResponseData<Restaurant>) => {
this.restaurants.value = res.data;
this.restaurants.isPending = false;
});
}
}