Optional: Declarative State page
Modify Restaurants component to derive state via RxJS streams
Overview
In this part, we will:
- Learn the differences between imperative and declarative state
- Learn some essential RxJS operators and static functions
- Update the restaurant component so states, cities & restaurants are RxJS streams
- Add additional streams to avoid the use of imperative logic
Note: You should complete Bitovi Academy’s RxJS training before attempting the following exercise. Although even if you haven’t, read on if you’re interested why you might want to use declarative state.
Imperative vs Declarative state
To understand the difference between imperative and declarative styles of programming we first need to review the concept of state. State is essentially the "remembered information" of a program, i.e the variables used as part of the program. Imperative & declarative styles differ in how the program specifies the state.
The code we’ve written thus far has been in an imperative style, i.e when events occur, code runs that changes the state of the program accordingly. The state is determined by actions throughout the program that directly modify the state. This model of programming is very familiar, although it can become quite difficult to trace the modifications to the state as an application grows in complexity.
<script type="module">
let x = '';
console.info('x: ' + x);
setInterval(() => {
x = x + 'A';
console.info('x: ' + x);
}, 1000);
// logs:
// x:
// (... 1 second passes)
// x: A
// (... 1 second passes)
// x: AA
// (... and so on)
</script>
In contrast, a declarative style of programming expresses state by specifying how values should be generated. i.e state specifies which events should be reacted to and what actions will occur to produce those state values. This subtle distinction has some very useful implications.
<script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/6.3.3/rxjs.umd.min.js"></script>
<script type="module">
const { timer } = rxjs;
const { map } = rxjs.operators;
// act on a number starting at 0 and incrementing every 1000ms afterwards
const x$ = timer(0, 1000).pipe(
// create a string of As the length of the incrementing number
map((length) => Array(length).fill('A').join(''))
);
x$.subscribe((value) => console.info('x: ' + value));
// logs:
// x:
// (... 1 second passes)
// x: A
// (... 1 second passes)
// x: AA
// (... an so on)
</script>
Declarative state, once you’re familiar with it, is typically easier to follow. Understanding how a piece of the program’s state is generated only requires reading the state’s definition. The actions that are part of the definition explain everything about how the state is created. In imperative code you’d need to read the code anywhere the state is modified.
Code using declarative state is often shorter than imperative code since you’re not needing to write as much flow control logic.
Declarative state can be less error prone. It’s typically more specific about how state is generated relative to imperative code, which may modify state under conditions which may at first seem correct, but end up having unintended consequences.
An additional benefit of Angular + RxJS is that declarative state can be used directly in the template with the async pipe, removing the need
for most subscriptions in our component. Avoiding subscriptions eliminates the need to manage them in onDestroy
.
Essential RxJS Operators & Functions
RxJS operators are the actions that run to modify values in a stream. There are dozens of operators that do things like transform individual values, filter values, combine streams, and much more. We’ll just be touching on a small selection of operators.
We’ll also demonstrate important RxJS static functions used to create and combine streams.
Creating A Stream
The of
function simply creates an observable that emits
the values passed to of
. This is often used when creating
demo streams or composing streams.
<script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/6.3.3/rxjs.umd.min.js"></script>
<script type="module">
const { of } = rxjs;
of(1, 2, 3, 4).subscribe((v) => {
console.info(v);
});
// logs:
// 1
// 2
// 3
// 4
</script>
In the solution of this exercise we’ll use of
to return a
stream during mergeMap
. Look at the
mergeMap
example below to see that in action.
Combining Streams
combineLatest
returns a
stream that emits arrays containing the most recent values of each stream. One caveat is that
combineLatest
will only start emitting arrays
when all input streams have emitted a value.
<script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/6.3.3/rxjs.umd.min.js"></script>
<script type="module">
const { of, combineLatest, zip, interval } = rxjs;
const { map } = rxjs.operators;
// emits the values every 750ms in order
const selectedHatStream$ = zip(
of('Bowler', 'Top', 'Baseball'),
interval(750)
).pipe(map(([value, num]) => value));
// emits the values every 2000ms in order
const selectedJacketStream$ = zip(
of('Trenchcoat', 'Tuxedo', 'Bomber'),
interval(2000)
).pipe(map(([value, num]) => value));
combineLatest(selectedHatStream$, selectedJacketStream$).subscribe(
([hat, jacket]) => {
console.info(`selected outfit: ${hat} hat & ${jacket} jacket`);
}
);
// logs:
// (... 2 seconds pass)
// selected outfit: Top hat & Trenchcoat jacket
// (... 250 milliseconds pass)
// selected outfit: Baseball hat & Trenchcoat jacket
// (... 1.75 seconds pass)
// selected outfit: Baseball hat & Tuxedo jacket
// (... 2 seconds pass)
// selected outfit: Baseball hat & Bomber jacket
</script>
Initializing A Stream
A common situation is working with streams that only produce a value after an event, for example when an HTTP request
completes or when a value changes in a form control. When using a stream like this in your components, you’ll likely
want to have an initial "base state" that your view can use during the initial render. In RxJS this is handled by the
startWith
operator, which emits a value when the
stream is first subscribed to.
<script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/6.3.3/rxjs.umd.min.js"></script>
<script type="module">
const { of } = rxjs;
const { delay, startWith } = rxjs.operators;
// emits an array of 3 values after a 1000ms delay, like a request returning results
const pseudoRequest$ = of([1, 2, 3]).pipe(delay(1000));
// immediately emits an empty array followed 1 second later by the array from pseudoRequest
const baseCaseAdded$ = pseudoRequest$.pipe(startWith([]));
baseCaseAdded$.subscribe((arr) => {
console.info('Contents: ' + JSON.stringify(arr));
});
// logs:
// Contents: []
// (... 1 second passes)
// Contents: [1,2,3]
</script>
Transforming The Values Of A Stream
When values are emitted from a stream it’s common to transform them in some way before they’re used by your application.
One operator used for this is the map
operator, which
takes an emitted value and returns a modified value that will be passed to the subsequent operators in the stream.
<script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/6.3.3/rxjs.umd.min.js"></script>
<script type="module">
const { of } = rxjs;
const { map } = rxjs.operators;
of(1, 2, 3)
.pipe(map((v) => v * 2))
.subscribe((v) => {
console.info('Value: ' + v);
});
// logs:
// Value: 2
// Value: 4
// Value: 6
</script>
Emitting Values From Another Stream
When using a stream you may want to emit values from another stream as part of the original stream. RxJS offers a
variety of ways to do this, but the one we’ll demonstrate is the
mergeMap
operator. Like the map operator it takes an
emitted value from a stream, but instead of returning a modified value it returns another stream whose emitted values
will be passed to the subsequent operators.
<script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/6.3.3/rxjs.umd.min.js"></script>
<script type="module">
const { of, interval, zip } = rxjs;
const { map, mergeMap, delay } = rxjs.operators;
// returns a stream that emits the price of a given menu item after a 500ms delay. this is how a request might operate
function pseudoPriceRequest(menuItem) {
if (menuItem === 'Truffle Noodles') {
return of(14.99).pipe(delay(500))
} else if (menuItem === 'Charred Octopus') {
return of(25.99).pipe(delay(500))
}
}
// emits the values every 2 seconds in order
const pseudoFormValueStream = zip(of('', 'Truffle Noodles', 'Charred Octopus', ''), interval(2000)).pipe(map(([value, num]) => value));
// stream that makes a "request" if provided a menu item name or returns "No Item Selected"
const pseudoPriceStream = pseudoFormValueStream.pipe(mergeMap((selectedItem) => {
if (selectedItem) {
return pseudoPriceRequest(selectedItem).pipe(map(price => '$' + price));
} else {
return of('No Item Selected');
}
}));
pseudoPriceStream.subscribe((price) => {
console.info('Price Of Selected Item: ' + price);
})
// logs:
// (... 2 seconds pass)
// Price Of Selected Item: No Item Selected
// (... 2.5 seconds pass)
// Price Of Selected Item: $14.99
// (... 2 seconds pass)
// Price Of Selected Item: $25.99
// (... 2 seconds pass)
// Price Of Selected Item: No Item Selected
</script>
Combining Previous And Current Emissions Of A Stream
If you need to compare the current (latest) emitted value with the previous value of a stream, you can use the pairwise
operator. Once there are at least 2 emitted values, it starts emitting an array: [previous, current]
.
<script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/6.3.3/rxjs.umd.min.js"></script>
<script type="module">
const { of, interval, zip } = rxjs;
const { map, pairwise } = rxjs.operators;
// emits the values every 2 seconds in order
const menuItems$ = zip(
of('Steamed Mussels', 'Truffle Noodles', 'Charred Octopus', 'Onion fries'),
interval(2000)
).pipe(map(([value, num]) => value));
menuItems$.pipe(pairwise()).subscribe(([previous, current]) => {
console.info('Previous: ' + previous, 'Current: ' + current);
});
// logs:
// (... 2 seconds pass), "Steamed Mussels" is emitted by menuItems$
// (... 2 seconds pass), "Truffle Noodles" is emitted by menuItems$
// "Previous: Steamed Mussels" "Current: Truffle Noodles"
// (... 2 seconds pass), "Charred Octopus" is emitted by menuItems$
// "Previous: Truffle Noodles" "Current: Charred Octopus"
// (... 2 seconds pass), "Onion fries" is emitted by menuItems$
// "Previous: Charred Octopus" "Current: Onion fries"
</script>
Handling Multiple Subscribers To A Stream
An advanced topic when working with streams is how streams behave when they have multiple subscribers. To understand this you first need an understanding of "cold" vs "hot" observables.
A "cold" observable is one that creates a new producer of events whenever they receive a new subscriber. An example is
observables returned from the Angular HttpClient
. Whenever there’s
a new subscriber to that observable a new request is made.
A "hot" observable is one that doesn’t create a new producer for every subscriber. Instead it shares a single producer among all the subscribers. An example of this could be an observable that listens for messages on an existing WebSocket connection. Whenever there’s a new subscriber to the observable, a new listener is added, but a new connection isn’t opened, the connection is being shared between the subscribers.
This distinction is clearly important, you wouldn’t want to make separate requests for states in every place that
you reference the states observable in the view. You need some way to make cold observables hot. To satisfy that
requirement RxJS contains a variety of ways to share the stream between subscribers. This is a particularly complex
topic so we’ll only be reviewing a single way, the
shareReplay
operator.
The shareReplay
operator essentially works by
making the preceding portion of the stream hot. Once the stream is subscribed to,
shareReplay
will share the results produced,
preventing multiple instances of the stream from running. That’s the "sharing" functionality of
shareReplay
, but it also performs the other
important function of "replaying".
In our template we have code that looks like:
<ng-container *ngIf="!states.isPending">
<option value="">Choose a state</option>
<option *ngFor="let state of states.value" value="{{state?.short}}">
{{state?.name}}
</option>
</ng-container>
This code poses a problem since the ngFor
doesn’t get rendered until after states.isPending === false
. If states was a
stream, isPending
would only be false after the response from the HTTP request was produced. After that ngFor
would
be rendered, subscribe to states
, and do... nothing. This is because the ngFor
subscribed late, after the data it
needed was already produced by the stream. ngFor
missed it’s chance to get that data.
What we need is for ngFor
to get replayed the last value emitted by the stream once it subscribes. shareReplay(1)
will buffer the last emission of the preceding stream, and replay it for any late subscribers. Now when ngFor
gets
rendered and subscribes, it will receive the successful HTTP request and render the list of state options.
<script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/6.3.3/rxjs.umd.min.js"></script>
<script type="module">
const { of, interval, zip } = rxjs;
const { map, shareReplay } = rxjs.operators;
// emits the values every 2 seconds in order
const pseudoFormValueStream$ = zip(
of('Truffle Noodles', 'Charred Octopus', 'Gunthorp Chicken'),
interval(2000)
).pipe(map(([value, num]) => value));
// prevents multiple instances of pseudoFormValueStream$ from being created for each subscriber
const sharedFormValues$ = pseudoFormValueStream$.pipe(shareReplay(1));
// subscriber 1, will subscribe to stream, starting values to be emitted
sharedFormValues$.subscribe((menuItem) => {
console.info('s1: ' + menuItem);
});
// subscriber 2, subscribes late, but still emits 'Truffle Noodles' because the stream replays it.
// afterwards works like subscriber 1, logging at the same time since they’re both listening to same hot observable.
setTimeout(() => {
sharedFormValues$.subscribe((menuItem) => {
console.info('s2: ' + menuItem);
});
}, 2500);
// subscriber 3, subscribes after the stream completes, but still emits 'Gunthorp Chicken' because the stream replays it.
setTimeout(() => {
sharedFormValues$.subscribe((menuItem) => {
console.info('s3: ' + menuItem);
});
}, 6500);
// logs:
// (... 2 seconds pass)
// s1: Truffle Noodles
// (... .5 seconds pass)
// s2: Truffle Noodles
// (... 1.5 seconds pass)
// s1: Charred Octopus
// s2: Charred Octopus
// (... 2 seconds pass)
// s1: Gunthorp Chicken
// s2: Gunthorp Chicken
// (... .5 seconds pass)
// s3: Gunthorp Chicken
</script>
To go more in depth about this topic check out these articles:
Problem
Convert the imperatively managed state in the restaurant component to declarative state.
Technical requirements
When you’re finished the component members states$
, cities$
& restaurants$
will be of the types Observable<Data<State>>
,
Observable<Data<City>>
and Observable<Data<Restaurant>>
respectively. Each will be defined as a set of RxJS
operators that either produce values from a response emitted by a service layer request, or produce values from changes
in a form control (which in turn may make a request).
You’ll define selectedState$
and selectedCity$
members to be of Type Observable<string>
to represent current form control values. To access form values as streams you’ll use the valueChanges
observable available in each FormControl
.
You’ll also add new single-responsibility streams:
enableStateSelect$
which enables the state select control once state values are availabletoggleCitySelect$
which enables/disables the city select control if loading is ongoing or no values are availableclearCityWhenStateChanges$
which clears the city select control value if the state select control has been changed
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 { delay, of } from 'rxjs';
import { ImageUrlPipe } from '../image-url.pipe';
import { Restaurant } from './restaurant';
import { Data, RestaurantComponent } from './restaurant.component';
import { City, RestaurantService, State } 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();
let restaurantOutput!: Data<Restaurant>;
fixture.componentInstance.restaurants$.subscribe(
(restaurants) => (restaurantOutput = restaurants)
);
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(restaurantOutput).toEqual(expectedRestaurants);
}));
it('should show a loading div while isPending is true', () => {
fixture.detectChanges();
const originalGetRestaurants = service.getRestaurants;
service.getRestaurants = () => of(restaurantAPIResponse).pipe(delay(100));
fixture.componentInstance.form.get('state')!.patchValue('CA');
fixture.componentInstance.form.get('city')!.patchValue('Sacramento');
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const loadingDiv = compiled.querySelector('.loading');
expect(loadingDiv).toBeTruthy();
service.getRestaurants = originalGetRestaurants;
});
it('should not show a loading div if isPending is false', () => {
fixture.detectChanges();
fixture.componentInstance.form.get('state')!.patchValue('CA');
fixture.componentInstance.form.get('city')!.patchValue('Sacramento');
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();
let stateOutput!: Data<State>;
fixture.componentInstance.states$.subscribe(
(states) => (stateOutput = states)
);
fixture.detectChanges();
const expectedStates = {
value: [
{ short: 'MO', name: 'Missouri' },
{ short: 'CA ', name: 'California' },
{ short: 'MI', name: 'Michigan' },
],
isPending: false,
};
expect(stateOutput).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();
let cityOutput!: Data<City>;
fixture.componentInstance.cities$.subscribe(
(cities) => (cityOutput = cities)
);
fixture.componentInstance.form.get('state')?.patchValue('CA');
fixture.detectChanges();
const expectedCities = {
value: [
{ name: 'Sacramento', state: 'CA' },
{ name: 'Oakland', state: 'CA' },
],
isPending: false,
};
expect(cityOutput).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', () => {
const stateFormControl = fixture.componentInstance.form.get('state')!;
expect(stateFormControl.enabled).toBe(false);
fixture.detectChanges();
expect(stateFormControl.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
let restaurantOutput!: Data<Restaurant>;
fixture.componentInstance.restaurants$.subscribe((restaurants) => {
restaurantOutput = restaurants;
});
fixture.componentInstance.form.get('state')?.patchValue('CA');
fixture.componentInstance.form.get('city')?.patchValue('Sacramento');
fixture.detectChanges();
expect(restaurantOutput.value.length).toEqual(2);
fixture.componentInstance.form.get('state')?.patchValue('MO');
fixture.detectChanges();
expect(restaurantOutput.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');
}));
});
If you’ve implemented the solution correctly, the tests will pass when you run npm run test
!
What you need to know
Creating streams of Form Control values with
valueChanges
member (you previously learned this in the Filter Cities by State section! ✔️)How to perform common RxJS operations like:
setting the initial value to be emitted
transforming a value emitted
combine previous and current values of a stream as an array
conditionally emit values into a stream from another stream
merge streams into a stream of arrays with values from each input stream
multicasting emissions of a "cold" observable and handle late subscribers
You’ve learnt all of the above as part of the earlier sections on this page! Completing the Bitovi Academy’s RxJS training will help however.
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 {
combineLatest,
map,
mergeMap,
Observable,
of,
pairwise,
shareReplay,
startWith,
Subject,
takeUntil,
tap,
} from 'rxjs';
import { Restaurant } from './restaurant';
import {
City,
ResponseData,
RestaurantService,
State,
} from './restaurant.service';
export interface Data<T> {
value: T[];
isPending: boolean;
}
const toData = map(
<T>(response: ResponseData<T>): Data<T> => ({
value: response.data,
isPending: false,
})
);
@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();
states$: Observable<Data<State>>;
cities$: Observable<Data<City>>;
restaurants$: Observable<Data<Restaurant>>;
selectedState$: Observable<string>;
selectedCity$: Observable<string>;
enableStateSelect$: Observable<Data<State>>;
toggleCitySelect$: Observable<Data<City>>;
clearCityWhenStateChanges$: Observable<[string, string]>;
private onDestroy$ = new Subject<void>();
constructor(
private restaurantService: RestaurantService,
private fb: FormBuilder
) {
this.selectedState$ = this.form.controls.state.valueChanges.pipe(
startWith('')
);
this.selectedCity$ = this.form.controls.city.valueChanges.pipe(
startWith('')
);
this.states$ = this.restaurantService
.getStates()
.pipe(
toData,
startWith({ isPending: true, value: [] }),
shareReplay({ bufferSize: 1, refCount: true })
);
this.enableStateSelect$ = this.states$.pipe(
takeUntil(this.onDestroy$),
tap((states) => {
if (states.value.length > 0) {
this.form.controls.state.enable();
}
})
);
this.cities$ = this.selectedState$.pipe(
mergeMap((state) => {
if (state) {
return this.restaurantService
.getCities(state)
.pipe(toData, startWith({ isPending: true, value: [] }));
} else {
return of({ isPending: false, value: [] });
}
}),
shareReplay({ bufferSize: 1, refCount: true })
);
this.toggleCitySelect$ = this.cities$.pipe(
takeUntil(this.onDestroy$),
tap((cities) => {
if (cities.value.length === 0) {
this.form.controls.city.disable({
onlySelf: true,
emitEvent: false,
});
} else {
this.form.controls.city.enable({
onlySelf: true,
emitEvent: false,
});
}
})
);
this.clearCityWhenStateChanges$ = this.selectedState$.pipe(
takeUntil(this.onDestroy$),
pairwise(),
tap(([previous, current]) => {
if (current && current !== previous) {
this.form.controls.city.setValue('');
}
})
);
this.restaurants$ = combineLatest([
this.selectedCity$,
this.selectedState$,
]).pipe(
mergeMap(([city, state]) => {
if (city && state) {
return this.restaurantService
.getRestaurants(state, city)
.pipe(toData, startWith({ isPending: true, value: [] }));
} else {
return of({ isPending: false, value: [] });
}
})
);
}
ngOnInit(): void {
this.enableStateSelect$.subscribe();
this.toggleCitySelect$.subscribe();
this.clearCityWhenStateChanges$.subscribe();
}
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 },
});
}
}
✏️ Update src/app/restaurant/restaurant.component.html
<div class="restaurants">
<h2 class="page-header">Restaurants</h2>
<form class="form" [formGroup]="form">
<div class="form-group">
<label>State</label>
<select
*ngIf="states$ | async as states"
class="formControl"
formControlName="state"
>
<option value="" *ngIf="states.isPending">Loading...</option>
<ng-container *ngIf="!states.isPending">
<option value="">Choose a state</option>
<option *ngFor="let state of states.value" [value]="state.short">
{{ state.name }}
</option>
</ng-container>
</select>
</div>
<div class="form-group">
<label>City</label>
<select
*ngIf="cities$ | async as cities"
class="formControl"
formControlName="city"
>
<option value="" *ngIf="cities.isPending">Loading...</option>
<ng-container *ngIf="!cities.isPending">
<option value="">Choose a city</option>
<option *ngFor="let city of cities.value" [value]="city.name">
{{ city.name }}
</option>
</ng-container>
</select>
</div>
</form>
<ng-container *ngIf="restaurants$ | async as restaurants">
<div class="restaurant loading" *ngIf="restaurants.isPending"></div>
<ng-container *ngIf="restaurants.value.length">
<div class="restaurant" *ngFor="let restaurant of restaurants.value">
<img
alt=""
src="{{ restaurant.images.thumbnail | imageUrl }}"
width="100"
height="100"
/>
<h3>{{ restaurant.name }}</h3>
<div class="address" *ngIf="restaurant.address">
{{ restaurant.address.street }}<br />{{ restaurant.address.city }},
{{ restaurant.address.state }} {{ restaurant.address.zip }}
</div>
<div class="hours-price">
$$$<br />
Hours: M-F 10am-11pm
<span class="open-now">Open Now</span>
</div>
<a class="btn" [routerLink]="['/restaurants', restaurant.slug]">
Details
</a>
<br />
</div>
</ng-container>
</ng-container>
</div>