Multi-parameter Services page
Learn how to make a service take multiple parameters.
Overview
In this part, we will:
- Update
getRestaurants
service method to take params - Update component to pass state and city params to service method
Problem
Now that we are able to capture a user’s state and city preferences, we want to only return restaurants in the selected city. Modify the getRestaurants
method in the src/app/restaurant/restaurant.service.ts file to take two string parameters, one for city, and one for state.
The requested URL with params should look like this: '/api/restaurants?filter[address.state]=IL&filter[address.city]=Chicago'
What you need to know
- How to use HttpParams (you learned this in the previous section! ✔️)
Technical requirements
In the src/app/restaurant/restaurant.component.ts file, update the call to the getRestaurants
service method to use the city and state values captured from the user’s form input.
How to verify your solution is correct
If you’ve implemented the solution correctly, when you use the select boxes to choose state and city, you should see a list of just restaurants from the selected city returned.
✏️ 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';
const restaurantAPIResponse = {
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',
},
],
};
class MockRestaurantService {
getRestaurants(state: string, city: string) {
return of(restaurantAPIResponse);
}
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>;
let service: RestaurantService;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [RouterTestingModule, ReactiveFormsModule],
declarations: [RestaurantComponent, ImageUrlPipe],
providers: [
{ provide: RestaurantService, useClass: MockRestaurantService },
],
}).compileComponents();
service = TestBed.inject(RestaurantService);
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);
}));
it('should call getRestaurants method with two string params', fakeAsync((): void => {
const getRestaurantsSpy = spyOn(service, 'getRestaurants').and.returnValue(
of(restaurantAPIResponse)
);
fixture.detectChanges();
fixture.componentInstance.form.get('state')?.patchValue('CA');
fixture.componentInstance.form.get('city')?.patchValue('Sacramento');
fixture.detectChanges();
tick();
expect(getRestaurantsSpy).toHaveBeenCalledWith('CA', 'Sacramento');
}));
});
✏️ Update the spec file src/app/restaurant/restaurant.service.spec.ts
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('IL', 'Chicago')
.subscribe((restaurants: ResponseData<Restaurant>) => {
expect(restaurants).toEqual(mockRestaurants);
});
const url =
'http://localhost:7070/restaurants?filter%5Baddress.state%5D=IL&filter%5Baddress.city%5D=Chicago';
// url parses to 'http://localhost:7070/restaurants?filter[address.state]=IL&filter[address.city]=Chicago'
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();
});
});
Solution
If you’ve implemented the solution correctly, when you run
npm run test
the tests will pass!
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(
state: string,
city: string
): Observable<ResponseData<Restaurant>> {
const params = new HttpParams()
.set('filter[address.state]', state)
.set('filter[address.city]', city);
return this.httpClient.get<ResponseData<Restaurant>>(
environment.apiUrl + '/restaurants',
{ params }
);
}
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 {
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(cityName => {
if (cityName) {
this.getRestaurants(state, cityName);
}
});
}
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(state: string, city: string): void {
this.restaurants.isPending = true;
this.restaurantService
.getRestaurants(state, city)
.pipe(takeUntil(this.onDestroy$))
.subscribe((res: ResponseData<Restaurant>) => {
this.restaurants.value = res.data;
this.restaurants.isPending = false;
});
}
}