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
  • Remove use of the Reactive Forms API and add several additional streams to completely 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.log('x: ' + x);
    setInterval(() => {
        x = x + 'A';
        console.log('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
    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.log('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, removing the need for most subscriptions in our component. Avoiding subscriptions eliminates the need to manage them in onDestroy.

Removing Reactive Forms

Though very convenient, Angular Reactive Forms use an imperative API for tasks like toggling if a form element is disabled. Since the goal of this exercise is to use declarative state as much as possible we'll be removing the use of Reactive Forms and replacing its functionality with streams we create. Some of those streams will control disabled state, one will control the selected value of a control, and others will be emitting the current value of form controls.

Creating Streams Of Form Control Values

The Subject class in RxJS is both an Observer and Observable. This has several implications but the relevant one for this exercise is that it both consumes and produces values. That means we can pass values into a Subject (via it's next method) and have them emitted as part of a stream. Perfect for turning values from a form control into a stream.

There are several implementations of Subject in RxJS, but a convenient one for form control observation is the BehaviorSubject. This implementation takes an initial value and emits the current value whenever it's subscribed to. We'll initialize the BehaviorSubject with the same value as the form control, and update it via the next method whenever the control changes. Due to that an accurate form value will be emitted whenever a subscription is made.

<script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/6.3.3/rxjs.umd.min.js"></script>
<div>Pet Type: <span class="output"></span></div>
<select onchange="window.subject.next(this.value)">
  <option value="cat" selected>Cat</option>
  <option value="dog">Dog</option>
  <option value="bird">Bird</option>
  <option value="fish">Fish</option>
</select>

<script type="module">
  const { BehaviorSubject } = rxjs;
  const outputEl = document.querySelector('.output');

  window.subject = new BehaviorSubject('cat');
  window.subject.subscribe((value) => {
    outputEl.innerHTML = value;
  });
</script>

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 several important RxJS static functions used to create and combine streams.

Creating A Stream

The of functions 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.log(v)
  })

  // logs:
  // 1
  // 2
  // 3
  // 4
</script>

In the solution of this exercise we'll use of to return a stream during flatMap. Look at the flatMap example below to see that in action.

Combining Streams

In the solution to this exercise we'll have to use two RxJS functions to combine streams. The first is merge and it works by emitting the values coming from multiple streams as a single stream.

<script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/6.3.3/rxjs.umd.min.js"></script>
<script type="module">
  const { of, merge, zip, interval } = rxjs;
  const { map } = rxjs.operators;

  // emits the values every 500ms in order
  const oddNumberStream = zip(of(1,3,5), interval(500)).pipe(map(([val, num]) => val));
  const evenNumberStream = zip(of(2,4,6), interval(500)).pipe(map(([val, num]) => val));

  merge(oddNumberStream, evenNumberStream).subscribe((v) => {
    console.log('merged stream: ' + v);
  })

  // logs:
  // (... 500ms pass)
  // merged stream: 1
  // merged stream: 2
  // (... 500ms pass)
  // merged stream: 3
  // merged stream: 4
  // (... 500ms pass)
  // merged stream: 5
  // merged stream: 6
</script>

The second is combineLatest which 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(([val, num]) => val));
  // emits the values every 2000ms in order
  const selectedJacketStream = zip(of('Trenchcoat', 'Tuxedo', 'Bomber'), interval(2000)).pipe(map(([val, num]) => val));

  combineLatest(selectedHatStream, selectedJacketStream).subscribe(([hat, jacket]) => {
    console.log(`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.log('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.log('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 flatMap 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, flatMap, delay } = rxjs.operators;

    // returns a stream that emits the price of a given car after a 500ms delay. this is how a request might operate
    function pseudoPriceRequest(carName) {
        if (carName === 'mustang') {
            return of(40000).pipe(delay(500))
        } else if (carName === 'camaro') {
            return of(38000).pipe(delay(500))
        }
    }

    // emits the values every 2 seconds in order
    const pseudoFormValueStream = zip(of('', 'mustang', 'camaro', ''), interval(2000)).pipe(map(([val, num]) => val));

    // stream that makes a "request" if provided a car name or returns "No Car Selected"
    const pseudoPriceStream = pseudoFormValueStream.pipe(flatMap((selectedCar) => {
        if (selectedCar) {
            return pseudoPriceRequest(selectedCar).pipe(map(price => '$' + price));
        } else {
            return of('No Car Selected');
        }
    }));

    pseudoPriceStream.subscribe((price) => {
        console.log('Price Of Selected Car: ' + price);
    })

    // logs:
    // (... 2 seconds pass)
    // Price Of Selected Car: No Car Selected
    // (... 2.5 seconds pass)
    // Price Of Selected Car: $40000
    // (... 2 seconds pass)
    // Price Of Selected Car: $38000
    // (... 2 seconds pass)
    // Price Of Selected Car: No Car Selected
</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 someway 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('mustang', 'camaro', 'corvette'), interval(2000)).pipe(map(([val, num]) => val));

    // 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((carName) => {
        console.log('s1: ' + carName);
    });

    // subscriber 2, subscribes late, but still emits 'mustang' 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((carName) => {
            console.log('s2: ' + carName);
        })
    }, 2500);

    // subscriber 3, subscribes after the stream completes, but still emits 'corvette' because the stream replays it.
    setTimeout(() => {
        sharedFormValues.subscribe((carName) => {
            console.log('s3: ' + carName);
        });
    }, 6500);

    // logs:
    // (... 2 seconds pass)
    // s1: mustang
    // (... .5 seconds pass)
    // s2: mustang
    // (... 1.5 seconds pass)
    // s1: camaro
    // s2: camaro
    // (... 2 seconds pass)
    // s1: corvette
    // s2: corvette
    // (... .5 seconds pass)
    // s3: corvette
</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 state, 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).

To access form values as streams you'll use BehaviorSubject instances which have .next($event.target.value) called on them during control change events.

You'll also add new streams to handle several functions that were previously handled by the imperative Reactive Forms API:

  • displayedCity which sets the city select control value to user input if there's been any, or clears it if the state select control has been changed
  • stateSelectDisabled which disables the state select control if loading is ongoing
  • citySelectDisabled which disables the city select control if loading is ongoing or no values are available

How to Verify Your Solution is Correct

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

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

import { RestaurantComponent } from './restaurant.component';
import { RestaurantService } from './restaurant.service';
import { ImageUrlPipe } from '../image-url.pipe';
import { FormsModule } from "@angular/forms";
import { Mock } from 'protractor/built/driverProviders';

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, city) {
    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 component: RestaurantComponent;
  let fixture: ComponentFixture<RestaurantComponent>;
  let injectedService;

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

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

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

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

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

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

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

  it('should set restaurants value to restaurants response data and set isPending to false', <any>fakeAsync((): void => {
    let restaurantOutput = null;
    const fixture = TestBed.createComponent(RestaurantComponent);
    fixture.detectChanges();
    tick();

    fixture.componentInstance.restaurants.subscribe((output) => {
      restaurantOutput = output;
    });

    fixture.componentInstance.selectedState.next('CA');
    fixture.componentInstance.selectedCity.next('Sacramento');
    fixture.detectChanges();
    let expectedRestaurants = {
      value: [{
        "name": "Poutine Palace",
        "slug": "poutine-palace",
        "images": {
          "thumbnail": "node_modules/place-my-order-assets/images/4-thumbnail.jpg",
          "owner": "node_modules/place-my-order-assets/images/3-owner.jpg",
          "banner": "node_modules/place-my-order-assets/images/2-banner.jpg"
        },
        "menu": {
          "lunch": [
            {
              "name": "Crab Pancakes with Sorrel Syrup",
              "price": 35.99
            },
            {
              "name": "Steamed Mussels",
              "price": 21.99
            },
            {
              "name": "Spinach Fennel Watercress Ravioli",
              "price": 35.99
            }
          ],
          "dinner": [
            {
              "name": "Gunthorp Chicken",
              "price": 21.99
            },
            {
              "name": "Herring in Lavender Dill Reduction",
              "price": 45.99
            },
            {
              "name": "Chicken with Tomato Carrot Chutney Sauce",
              "price": 45.99
            }
          ]
        },
        "address": {
          "street": "230 W Kinzie Street",
          "city": "Green Bay",
          "state": "WI",
          "zip": "53205"
        },
        "_id": "3ZOZyTY1LH26LnVw"
      },
        {
          "name": "Cheese Curd City",
          "slug": "cheese-curd-city",
          "images": {
            "thumbnail": "node_modules/place-my-order-assets/images/2-thumbnail.jpg",
            "owner": "node_modules/place-my-order-assets/images/3-owner.jpg",
            "banner": "node_modules/place-my-order-assets/images/2-banner.jpg"
          },
          "menu": {
            "lunch": [
              {
                "name": "Ricotta Gnocchi",
                "price": 15.99
              },
              {
                "name": "Gunthorp Chicken",
                "price": 21.99
              },
              {
                "name": "Garlic Fries",
                "price": 15.99
              }
            ],
            "dinner": [
              {
                "name": "Herring in Lavender Dill Reduction",
                "price": 45.99
              },
              {
                "name": "Truffle Noodles",
                "price": 14.99
              },
              {
                "name": "Charred Octopus",
                "price": 25.99
              }
            ]
          },
          "address": {
            "street": "2451 W Washburne Ave",
            "city": "Green Bay",
            "state": "WI",
            "zip": "53295"
          },
          "_id": "Ar0qBJHxM3ecOhcr"
        }],
      isPending: false
    }
    expect(restaurantOutput).toEqual(expectedRestaurants);
  }));

  it('should show a loading div while restaurant request is waiting', () => {
    const fixture = TestBed.createComponent(RestaurantComponent);
    const origGetRestaurants = injectedService.getRestaurants;
    fixture.detectChanges();
    injectedService.getRestaurants = () => of(restaurantAPIResponse).pipe(delay(500));
    fixture.componentInstance.selectedState.next('CA');
    fixture.componentInstance.selectedCity.next('Sacramento');
    fixture.detectChanges();
    const compiled = fixture.debugElement.nativeElement;
    let loadingDiv = compiled.querySelector('.loading');
    expect(loadingDiv).toBeTruthy();
    injectedService.getRestaurants = origGetRestaurants;
  });

  it('should not show a loading div while restaurant request is complete', () => {
    const fixture = TestBed.createComponent(RestaurantComponent);
    const origGetRestaurants = injectedService.getRestaurants;
    injectedService.getRestaurants = () => of(restaurantAPIResponse);
    fixture.detectChanges();
    const compiled = fixture.debugElement.nativeElement;
    let loadingDiv = compiled.querySelector('.loading');
    expect(loadingDiv).toBe(null);
    injectedService.getRestaurants = origGetRestaurants;
  });

  it('should have a city and state selected subjects', () => {
    const fixture = TestBed.createComponent(RestaurantComponent);
    fixture.detectChanges();
    expect(fixture.componentInstance.selectedState).toBeTruthy();
    expect(fixture.componentInstance.selectedCity).toBeTruthy();
  });

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

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

  it('should set states value to states response data and set isPending to false', <any>fakeAsync((): void => {
    let stateOutput = null;
    const fixture = TestBed.createComponent(RestaurantComponent);
    fixture.detectChanges();
    tick();

    fixture.componentInstance.states.subscribe((output) => {
      stateOutput = output;
    });

    fixture.detectChanges();
    let 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', <any>fakeAsync((): void => {
    const fixture = TestBed.createComponent(RestaurantComponent);
    fixture.detectChanges();
    tick();
    fixture.detectChanges();
    const compiled = fixture.debugElement.nativeElement;
    let stateOption = compiled.querySelector('select.state option:nth-child(2)');
    expect(stateOption.textContent).toEqual('Missouri');
    expect(stateOption.value).toEqual('MO');
  }));

  it('should set cities value to cities response data and set isPending to false', <any>fakeAsync((): void => {
    let citiesOutput = null;
    const fixture = TestBed.createComponent(RestaurantComponent);
    fixture.detectChanges();
    tick();

    fixture.componentInstance.cities.subscribe((output) => {
      citiesOutput = output;
    });

    fixture.componentInstance.selectedState.next('CA');
    fixture.detectChanges();
    let expectedCities = {
      value: [
        {"name":"Sacramento","state":"CA"},
        {"name":"Oakland","state":"CA"}
      ],
      isPending: false
    }
    expect(citiesOutput).toEqual(expectedCities);
  }));

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

  it('state dropdown should be disabled until states are populated', <any>fakeAsync((): void => {
    let stateOutput = null;
    let stateDisabledOutput = null;
    const fixture = TestBed.createComponent(RestaurantComponent);
    const originalGetStates = injectedService.getStates;

    // returns populated data 100ms after subscription
    injectedService.getStates = () => {
      return timer(100).pipe(
          mapTo({
            data: [
              {"short": "MO", "name": "Missouri"},
              {"short": "CA", "name": "California"},
              {"short": "MI", "name": "Michigan"}]
          }),
      );
    };

    fixture.detectChanges();
    fixture.componentInstance.states.subscribe((output) => {
      stateOutput = output;
    });
    fixture.componentInstance.stateSelectDisabled.subscribe((output) => {
      stateDisabledOutput = output;
    });

    expect(stateDisabledOutput).toBe(true);

    tick(100);
    fixture.detectChanges();

    expect(stateDisabledOutput).toBe(false);

    injectedService.getStates = originalGetStates;
  }));

  it('city dropdown should be disabled until cities are populated', <any>fakeAsync((): void => {
    const fixture = TestBed.createComponent(RestaurantComponent);
    let cityDisabledOutput = null;

    fixture.detectChanges(); //detecting changes for createForm func to be called
    fixture.componentInstance.citySelectDisabled.subscribe((output) => {
      cityDisabledOutput = output;
    });

    expect(cityDisabledOutput).toBe(true);
    fixture.componentInstance.selectedState.next('CA');
    fixture.detectChanges();

    expect(cityDisabledOutput).toBe(false);
  }));

  it('should reset list of cities when new state is selected', <any>fakeAsync((): void => {
    let restaurantOutput = null;
    const fixture = TestBed.createComponent(RestaurantComponent);
    fixture.detectChanges(); //detecting changes for createForm func to be called

    fixture.componentInstance.restaurants.subscribe((output) => {
      restaurantOutput = output;
    });

    fixture.componentInstance.selectedState.next('CA');
    fixture.componentInstance.selectedCity.next('Sacramento');
    fixture.detectChanges();
    expect(restaurantOutput.value.length).toEqual(2);
    fixture.componentInstance.selectedState.next('MO');
    fixture.detectChanges();
    expect(restaurantOutput.value.length).toEqual(0);
  }));
});

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

What You Need to Know

  • How to perform common RxJS operations like:
    • setting the initial value to be emitted
    • transforming a value emitted
    • conditionally emit values into a stream from another stream
    • merge streams into a single stream of values
    • merge streams into a stream of arrays with values from each input stream
    • use a BehaviorSubject to capture the state of a form element
    • 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

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

import { Component, OnInit } from '@angular/core';
import { BehaviorSubject, Observable, of, combineLatest, merge } from 'rxjs';
import { startWith, map, flatMap, shareReplay } from 'rxjs/operators';

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

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

const toData = map(function<T>(response: ResponseData<T>) : Data<T> {
  return {
    value: response.data,
    isPending: false
  }
});

@Component({
  selector: 'pmo-restaurant',
  templateUrl: './restaurant.component.html',
  styleUrls: ['./restaurant.component.less']
})
export class RestaurantComponent implements OnInit {
  public restaurants: Observable<Data<Restaurant>>;
  public states: Observable<Data<State>>;
  public cities: Observable<Data<City>>;

  public selectedState = new BehaviorSubject('');
  public selectedCity = new BehaviorSubject('');
  public displayedCity: Observable<string>;
  public stateSelectDisabled: Observable<boolean>;
  public citySelectDisabled: Observable<boolean>;

  constructor(
    private restaurantService: RestaurantService
  ) {}

  ngOnInit() {
    this.states = this.restaurantService.getStates().pipe(
      toData,
      startWith({ isPending: true, value: [] }),
      shareReplay(1),
    );
    this.stateSelectDisabled = this.states.pipe(
        map(states => states.value.length === 0)
    );

    this.cities = this.selectedState.pipe(
        flatMap((state) => {
          if (state) {
            return this.restaurantService.getCities(state).pipe(
              toData,
              startWith({ isPending: true, value: [] })
            )
          } else {
            return of({ isPending: false, value: [] });
          }
        }),
        shareReplay(1),
    );
    this.citySelectDisabled = this.cities.pipe(
      map(cities => cities.value.length === 0)
    );
    this.displayedCity = merge(
      this.selectedState.pipe(map(() => '')),
      this.selectedCity
    );

    this.restaurants = combineLatest(this.displayedCity, this.selectedState).pipe(
      flatMap(([city, state]) => {
        if (city && state) {
          return this.restaurantService.getRestaurants(state, city).pipe(
            toData,
            startWith({ isPending: true, value: [] })
          )
        } else {
          return of({ isPending: false, value: [] });
        }
      }),
      shareReplay(1),
    );
  }
}

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

<div class="restaurants">
  <h2 class="page-header">Restaurants</h2>
  <form class="form">
    <div class="form-group">
      <label>State</label>
      <select class="formControl state" (input)="selectedState.next($event.target.value)"
              [disabled]="stateSelectDisabled | async">
        <option value="" *ngIf="(states | async).isPending">Loading...</option>
        <ng-container *ngIf="!((states | async).isPending)">
          <option value="">Choose a state</option>
          <option *ngFor="let state of (states | async).value" value="{{state.short}}">{{state.name}}</option>
        </ng-container>
      </select>
    </div>
    <div class="form-group">
      <label>City</label>
      <select class="formControl city" (input)="selectedCity.next($event.target.value)"
              [disabled]="citySelectDisabled | async"
              [value]="displayedCity | async">
        <option value="" *ngIf="(cities | async).isPending">Loading...</option>
        <ng-container *ngIf="!((cities | async).isPending)">
          <option value="">Choose a city</option>
          <option *ngFor="let city of (cities | async).value" value="{{city.name}}">{{city.name}}</option>
        </ng-container>
      </select>
    </div>
  </form>

  <div class="restaurant loading" *ngIf="(restaurants | async).isPending"></div>
  <ng-container *ngIf="(restaurants | async).value.length">
    <div class="restaurant" *ngFor="let restaurant of (restaurants | async).value">

      <img 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>
</div>

Advanced Implementation

Below is a more comprehensive approach to implementing the sort of features seen in the solution above. It takes things further by handling additional cases and abstracting the streams to aid in reuse of these patterns throughout an application. Since we won't be going over the details of this implementation, a very solid understanding of the above solution and RxJS in general is recommended in order to infer the reasoning behind the design of solution below:

import { Component, OnInit } from '@angular/core';
import { BehaviorSubject, Observable, UnaryFunction, of, combineLatest, merge, pipe } from 'rxjs';
import { startWith, map, flatMap, shareReplay, catchError, tap } from 'rxjs/operators';

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

// represent all the possible states of a request
export interface RequestStatus<T> {
  data: Array<T>;
  error: string,
  // TODO: represent statuses as enum?
  isPending: boolean;
  isResolved: boolean;
  isRejected: boolean;
  isAwaitingInput: boolean;
}

// in a complete app this would be a function exported by some component that manages popup messages rather than the trivial implementation seen here
function openErrorPopup(message) {
  alert(message);
}

// transform ResponseData into successful RequestStatus
const responseToRequestStatus = map(function<T>(response: ResponseData<T>): RequestStatus<T> {
  return {
    data: response.data,
    error: null,
    isPending: false,
    isResolved: true,
    isRejected: false,
    isAwaitingInput: false,
  }
});

// an operator that takes input for a request, makes a request via a provided function, and provides an appropriate RequestStatus
function makeRequest<T>(requestFunction: (args:any) => Observable<ResponseData<T>>)
    :UnaryFunction<Observable<any>, Observable<RequestStatus<T>>> {
  return pipe(
      flatMap(requestArguments => {
        const responseStream = requestFunction(requestArguments);

        // awaiting input case where the requestFunction hasn't made a request yet
        if (!responseStream) {
          return of({
            data: [],
            error: null,
            isPending: false,
            isResolved: false,
            isRejected: false,
            isAwaitingInput: true
          })
        }

        return responseStream.pipe(
            responseToRequestStatus, // handle the successful case
            startWith({ // handle the in-progress case
              data: [],
              error: null,
              isPending: true,
              isResolved: false,
              isRejected: false,
              isAwaitingInput: false,
            }),
            catchError((err) => { // handle the failed case
              return of({
                data: [],
                error: err.message || JSON.stringify(err, null, 2),
                isPending: false,
                isResolved: false,
                isRejected: true,
                isAwaitingInput: false,
              });
            })
        )
      }),
      shareReplay(1))
}

// operator that checks if the incoming requests haven't yet succeeded
const isRequestIncomplete = pipe(map<RequestStatus<any>, boolean>((requestStatus) => !requestStatus.isResolved));

// operator that shows failed RequestStatus emissions as popups, used when the isRejected case isn't handled in the view
// TODO: rather than tap, provide a stream to some singleton component?
//       would require a subscription though, possibly triggering requests early :/
const showErrorsAsPopups = pipe(
    tap<RequestStatus<any>>(request => {
      if (request.isRejected) openErrorPopup(request.error)
    }),
    shareReplay(1)
);

@Component({
  selector: 'pmo-restaurant',
  templateUrl: './restaurant.component.html',
  styleUrls: ['./restaurant.component.less']
})
export class RestaurantComponent implements OnInit {
  public restaurants: Observable<RequestStatus<Restaurant>>;
  public states: Observable<RequestStatus<State>>;
  public cities: Observable<RequestStatus<City>>;

  public selectedState = new BehaviorSubject('');
  public selectedCity = new BehaviorSubject('');
  public displayedCity: Observable<string>;
  public stateSelectDisabled: Observable<boolean>;
  public citySelectDisabled: Observable<boolean>;

  constructor(
      private restaurantService: RestaurantService
  ) {}

  ngOnInit() {
    this.states = of(null).pipe(
        makeRequest(() => this.restaurantService.getStates()),
        showErrorsAsPopups
    );
    this.stateSelectDisabled = this.states.pipe(isRequestIncomplete);

    this.cities = this.selectedState.pipe(
        makeRequest((state) => state ? this.restaurantService.getCities(state) : null),
        showErrorsAsPopups
    );
    this.citySelectDisabled = this.cities.pipe(isRequestIncomplete);
    this.displayedCity = merge(
        this.selectedState.pipe(map(() => '')),
        this.selectedCity
    );

    this.restaurants = combineLatest(this.displayedCity, this.selectedState).pipe(
        makeRequest(([city, state]) => city && state ? this.restaurantService.getRestaurants(state, city) : null)
    )
  }
}

<div class="restaurants">
  <h2 class="page-header">Restaurants</h2>
  <form class="form">
    <div class="form-group">
      <label>State</label>
      <select class="formControl state" (input)="selectedState.next($event.target.value)"
              [disabled]="stateSelectDisabled | async">
        <option value="" *ngIf="(states | async).isPending">Loading...</option>
        <ng-container *ngIf="!((states | async).isPending)">
          <option value="">Choose a state</option>
          <option *ngFor="let state of (states | async).data" value="{{state.short}}">{{state.name}}</option>
        </ng-container>
      </select>
    </div>
    <div class="form-group">
      <label>City</label>
      <select class="formControl city" (input)="selectedCity.next($event.target.value)"
              [disabled]="citySelectDisabled | async"
              [value]="displayedCity | async">
        <option value="" *ngIf="(cities | async).isPending">Loading...</option>
        <ng-container *ngIf="!((cities | async).isPending)">
          <option value="">Choose a city</option>
          <option *ngFor="let city of (cities | async).data" value="{{city.name}}">{{city.name}}</option>
        </ng-container>
      </select>
    </div>
  </form>

  <div class="restaurant loading" *ngIf="(restaurants | async).isPending"></div>
  <div class="restaurant message"  *ngIf="(restaurants | async).isAwaitingInput">
    Select a state & city to load restaurants.
  </div>
  <div class="restaurant message" *ngIf="(restaurants | async).isResolved && (restaurants | async).data.length === 0">
    There are no restaurants available to order from in this city.
  </div>
  <div class="restaurant error message" *ngIf="(restaurants | async).isRejected">
    An error has occurred while loading restaurants:
    <pre>{{(restaurants | async).error}}</pre>
  </div>
  <ng-container *ngIf="(restaurants | async).data.length">
    <div class="restaurant" *ngFor="let restaurant of (restaurants | async).data">

      <img 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>
</div>