Rendering Data in Components page

Learn how to render API data into HTML with a view.

Overview

In this part, we will:

  • Import new service into Restaurant Component
  • Call getRestaurants() method in component
  • Change restaurant markup to use new restaurant object

Problem 1: Make RestaurantComponent use the getRestaurants function

In this section, we will change RestaurantComponent to actually get data from the service API. Instead of two hard coded restaurants, we will see a longer list:

P1: What you need to know

  • How to inject a service into a component
  • How to subscribe to an observable

Inject a service into a component

To use a service in a component, we use dependency injection to pass the service in the component constructor function. We’re then able to access methods on it for use in our component.

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

@Injectable({
  providedIn: 'root'
})
class UsersService {
  private users = [
  {
    name: 'Jennifer',
    id: 1,
    role: 'admin'
  },
  {
    name: 'Steve',
    id: 2,
    role: 'user'
  },
  {
    name: 'Alice',
    id: 3,
    role: 'developer'
  }]

  constructor() { }

  getUsers() {
    return this.users;
  }

  getUser(id: number) {
    return this.users.find(x => x.id === id)
  }
}

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

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

@Component({
  selector: 'home-component',
  template: `
    <p>A home component!</p>
    <ul>
      <li *ngFor="let user of users">
        {{user.name}}
      </li>
    </ul>
  `
})
class HomeComponent implements OnInit{
  private users: any[] = [];

  constructor(private usersService: UsersService) {
  
  }

  ngOnInit() {
    this.users = this.usersService.getUsers();
  }
}
//THIS IS A HACK JUST FOR CODEPEN TO WORK
HomeComponent.parameters = [UsersService];

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

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

const { platformBrowserDynamic } = ng.platformBrowserDynamic;

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


Subscribe to an Observable

The result of getRestaurants() is an observable. Use subscribe to listen to when an RxJS observable changes:

<script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/6.2.1/rxjs.umd.js"></script>
<script type="typescript">
const { Observable } = rxjs;

const observable = Observable.create(function (observer) {
  observer.next(1);
  observer.next(2);
  observer.next(3);
});

observable.subscribe(function subscriber(value) {
  console.info('got value ' + value);
  // Logs 1, 2, 3
});
</script>

P1: Technical requirements

  1. Change RestaurantComponent’s restaurants property definition:
import { Component, OnInit } from '@angular/core';

const fakeRestaurants = [
  {
    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',
  },
];

@Component({
  selector: 'pmo-restaurant',
  templateUrl: './restaurant.component.html',
  styleUrl: './restaurant.component.css',
})
export class RestaurantComponent implements OnInit {
  restaurants: any[] = [];

  ngOnInit(): void {
    setTimeout(() => {
      this.restaurants = fakeRestaurants;
    }, 500);
  }
}

  1. Use RestaurantService’s getRestaurants to get an array of restaurants and set it on RestaurantComponent’s restaurants property.

P1: How to verify your solution is correct

You should be able see a list of all restaurants when you navigate to localhost:4200/restaurants, instead of the two that were previously hard-coded.

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

import {
  ComponentFixture,
  fakeAsync,
  TestBed,
  tick,
} from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { of } from 'rxjs';
import { ImageUrlPipe } from '../image-url.pipe';
import { RestaurantComponent } from './restaurant.component';
import { RestaurantService } from './restaurant.service';

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

describe('RestaurantComponent', () => {
  let fixture: ComponentFixture<RestaurantComponent>;

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

    fixture = TestBed.createComponent(RestaurantComponent);
  });

  it('should create', () => {
    const component: RestaurantComponent = fixture.componentInstance;
    expect(component).toBeTruthy();
  });

  it('should render title in a h2 tag', () => {
    const compiled = fixture.nativeElement as HTMLElement;
    expect(compiled.querySelector('h2')?.textContent).toContain('Restaurants');
  });

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

  it('should have two .restaurant divs', fakeAsync((): void => {
    fixture.detectChanges();
    tick(501);
    fixture.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.detectChanges();
    const compiled = fixture.nativeElement as HTMLElement;
    expect(compiled.querySelector('.restaurant h3')?.textContent).toContain(
      'Poutine Palace'
    );
  }));

  it('should set restaurants member to response of getRestaurants method', fakeAsync((): void => {
    const fixture = TestBed.createComponent(RestaurantComponent);
    fixture.detectChanges();
    tick();
    fixture.detectChanges();
    const expectedRestaurants = [
      {
        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',
      },
    ];
    expect(fixture.componentInstance.restaurants).toEqual(expectedRestaurants);
  }));
});

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

import { Location } from '@angular/common';
import { HttpClientModule } from '@angular/common/http';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import {
  ComponentFixture,
  fakeAsync,
  flush,
  TestBed,
  tick,
} from '@angular/core/testing';
import { Router } from '@angular/router';
import { of } from 'rxjs';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { HomeComponent } from './home/home.component';
import { ImageUrlPipe } from './image-url.pipe';
import { RestaurantComponent } from './restaurant/restaurant.component';
import { RestaurantService } from './restaurant/restaurant.service';

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

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

  getCities(state: string) {
    return of({
      data: [
        { name: 'Sacramento', state: 'CA' },
        { name: 'Oakland', state: 'CA' },
      ],
    });
  }

  getRestaurant(slug: string) {
    return of({
      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',
    });
  }
}

describe('AppComponent', () => {
  let fixture: ComponentFixture<AppComponent>;
  let location: Location;
  let router: Router;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [AppRoutingModule, HttpClientModule],
      declarations: [
        AppComponent,
        HomeComponent,
        RestaurantComponent,
        ImageUrlPipe,
      ],
      providers: [
        { provide: RestaurantService, useClass: MockRestaurantService },
      ],
      schemas: [NO_ERRORS_SCHEMA],
    }).compileComponents();

    fixture = TestBed.createComponent(AppComponent);
    location = TestBed.inject(Location);
    router = TestBed.inject(Router);
  });

  it('should create the app', () => {
    const app = fixture.componentInstance;
    expect(app).toBeTruthy();
  });

  it(`should have as title 'place-my-order'`, () => {
    const app = fixture.componentInstance;
    expect(app.title).toEqual('place-my-order');
  });

  it('should render title in a h1 tag', () => {
    fixture.detectChanges();
    const compiled = fixture.nativeElement as HTMLElement;
    expect(compiled.querySelector('h1')?.textContent).toContain(
      'place-my-order.com'
    );
  });

  it('should render the HomeComponent with router navigates to "/" path', fakeAsync(() => {
    fixture.detectChanges();
    const compiled = fixture.nativeElement as HTMLElement;
    router.navigate(['']).then(() => {
      expect(location.path()).toBe('');
      expect(compiled.querySelector('pmo-home')).not.toBe(null);
    });
  }));

  it('should render the RestaurantsComponent with router navigates to "/restaurants" path', fakeAsync(() => {
    fixture.detectChanges();
    const compiled = fixture.nativeElement as HTMLElement;
    router.navigate(['restaurants']).then(() => {
      expect(location.path()).toBe('/restaurants');
      expect(compiled.querySelector('pmo-restaurant')).not.toBe(null);
    });
  }));

  it('should have the home navigation link href set to "/"', () => {
    fixture.detectChanges();
    const compiled = fixture.nativeElement as HTMLElement;
    const homeLink = compiled.querySelector('li a');
    const href = homeLink?.getAttribute('href');
    expect(href).toEqual('/');
  });

  it('should have the restaurants navigation link href set to "/restaurants"', () => {
    fixture.detectChanges();
    const compiled = fixture.nativeElement as HTMLElement;
    const restaurantsLink = compiled.querySelector('li:nth-child(2) a');
    const href = restaurantsLink?.getAttribute('href');
    expect(href).toEqual('/restaurants');
  });

  it('should make the home navigation link class active when the router navigates to "/" path', fakeAsync(() => {
    const compiled = fixture.nativeElement as HTMLElement;
    router.navigate(['']);
    fixture.detectChanges();
    tick();
    fixture.detectChanges();

    const homeLinkLi = compiled.querySelector('li');
    expect(homeLinkLi?.classList).toContain('active');
    expect(compiled.querySelectorAll('.active').length).toBe(1);
    flush();
  }));

  it('should make the restaurants navigation link class active when the router navigates to "/restaurants" path', fakeAsync(() => {
    const compiled = fixture.nativeElement as HTMLElement;
    router.navigate(['restaurants']);
    fixture.detectChanges();
    tick();
    fixture.detectChanges();

    expect(location.path()).toBe('/restaurants');
    const restaurantsLinkLi = compiled.querySelector('li:nth-child(2)');
    expect(restaurantsLinkLi?.classList).toContain('active');
    expect(compiled.querySelectorAll('.active').length).toBe(1);
    flush();
  }));
});

Hint: Call the getRestaurants method in the ngOnInit method.

P1: Solution

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

Click to see the solution ✏️ Update src/app/restaurant/restaurant.component.ts as follows:

import { Component, OnInit } from '@angular/core';
import { Restaurant } from './restaurant';
import { ResponseData, RestaurantService } from './restaurant.service';

@Component({
  selector: 'pmo-restaurant',
  templateUrl: './restaurant.component.html',
  styleUrl: './restaurant.component.css',
})
export class RestaurantComponent implements OnInit {
  restaurants: Restaurant[] = [];

  constructor(private restaurantService: RestaurantService) {}

  ngOnInit(): void {
    this.restaurantService.getRestaurants().subscribe((res: ResponseData) => {
      this.restaurants = res.data;
    });
  }
}

Problem 2: Show a loading state while restaurants are being requested

Sometimes the server can take a long time to respond. It’s a better experience for the user to show a loading icon to the user while data is loading:

This icon will be shown with the following HTML:

<div class="restaurant loading"></div>

P2: What you need to know

  • How to write an interface (you learned this in the previous section! ✔️)
  • How to conditionally show html blocks (you learned this in a previous section! ✔️)

P2: Technical requirements

  1. Create a new interface Data that represents the following object:

    let data = {
      value: [], // array of restaurants
      isPending: false, // boolean
    };
    
  2. Change the restaurants member to use the new Data type in the RestaurantsComponent.

  3. Right before you call the getRestaurants method, set the restaurants isPending value to true.

  4. Once the getRestaurants response is received, set the restaurants value to the response data and isPending value to false.

  5. Update the html to match the new restaurant object values and to show the following HTML while isPending is true:

<div class="restaurant loading"></div>

P2: How to verify your solution is correct

You should be able see a list of restaurants when you navigate to localhost:4200/restaurants!

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

import {
  ComponentFixture,
  fakeAsync,
  TestBed,
  tick,
} from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { of } from 'rxjs';
import { ImageUrlPipe } from '../image-url.pipe';
import { RestaurantComponent } from './restaurant.component';
import { RestaurantService } from './restaurant.service';

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

describe('RestaurantComponent', () => {
  let fixture: ComponentFixture<RestaurantComponent>;

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

    fixture = TestBed.createComponent(RestaurantComponent);
  });

  it('should create', () => {
    const component: RestaurantComponent = fixture.componentInstance;
    expect(component).toBeTruthy();
  });

  it('should render title in a h2 tag', () => {
    const compiled = fixture.nativeElement as HTMLElement;
    expect(compiled.querySelector('h2')?.textContent).toContain('Restaurants');
  });

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

  it('should have two .restaurant divs', fakeAsync((): void => {
    fixture.detectChanges();
    tick(501);
    fixture.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.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', fakeAsync((): void => {
    const fixture = TestBed.createComponent(RestaurantComponent);
    fixture.detectChanges();
    tick();
    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);
  });
});

P2: Solution

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

Click to see the solution ✏️ Update src/app/restaurant/restaurant.component.ts to:

import { Component, OnInit } from '@angular/core';
import { Restaurant } from './restaurant';
import { ResponseData, RestaurantService } from './restaurant.service';

export interface Data {
  value: Restaurant[];
  isPending: boolean;
}

@Component({
  selector: 'pmo-restaurant',
  templateUrl: './restaurant.component.html',
  styleUrl: './restaurant.component.css',
})
export class RestaurantComponent implements OnInit {
  restaurants: Data = {
    value: [],
    isPending: false,
  };

  constructor(private restaurantService: RestaurantService) {}

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

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

<div class="restaurants">
  <h2 class="page-header">Restaurants</h2>
  <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>
</div>

Did you know?

You may have noticed in our markup there’s another use of routerLink:

<a class="btn" [routerLink]="['/restaurants', restaurant.slug]">
  Details
</a>

One of the ways to create a link is to pass in the individual parts to the routerLink directive. This will generate the path /restaurants/crab-cafe for the "crab cafe" restaurant from its slug.