Item Total Pipe page

Write an Item Total Pipe to help manage item total calculation across our application.

Overview

In this part, we will:

  • Create a new Pipe called Item Totals
  • Use Angular Default Currency Pipe

Problem 1: Implementing and Using Item Total Pipe

Now that our application is coming together nicely, you might have noticed we are repeating the same code to calculate the total prices of our items. We can simplify this by creating an Item Total Pipe which we will use in the list.component.html template file and call directly using transform within order.component.ts file. The Pipe will transform an array of items and return the total sum of the price of each item.

After implementing this Pipe, you should be able to remove the total method from list.component.ts.

P1: What you need to know

How to:

  • Generate a Pipe using Angular CLI (you previously learned this in the Creating Pipes section! ✔️)
    ng g pipe itemTotal
    
  • Provide a Pipe globally to the entire application, so it can be used in any component

Provide Pipe Globally

Unlike services, Pipes are not readily injectable into our components. In order to be able to use a Pipe in a component’s .ts file, the Pipe has to be provided by a module. To provide the Pipe just created in the app module, simply add the Pipe to the providers array in the app.module.ts file. Learn More.

By including the Pipe in a component’s constructor, you gain the ability to run the Pipe using its transform method.

✏️ Update src/app/app.module.ts

import { HttpClientModule } from '@angular/common/http';
import { NgModule } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
import { BrowserModule } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { TabsModule } from 'ngx-bootstrap/tabs';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { HomeComponent } from './home/home.component';
import { RestaurantComponent } from './restaurant/restaurant.component';
import { ImageUrlPipe } from './image-url.pipe';
import { DetailComponent } from './restaurant/detail/detail.component';
import { OrderComponent } from './order/order.component';
import { MenuItemsComponent } from './order/menu-items/menu-items.component';
import { OnlyNumbersDirective } from './only-numbers.directive';
import { HistoryComponent } from './order/history/history.component';
import { ListComponent } from './order/list/list.component';
import { ItemTotalPipe } from './item-total.pipe';

@NgModule({
  declarations: [
    AppComponent,
    HomeComponent,
    RestaurantComponent,
    ImageUrlPipe,
    DetailComponent,
    OrderComponent,
    MenuItemsComponent,
    OnlyNumbersDirective,
    HistoryComponent,
    ListComponent,
    ItemTotalPipe
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    HttpClientModule,
    ReactiveFormsModule,
    BrowserAnimationsModule,
    TabsModule.forRoot()
  ],
  providers: [ItemTotalPipe],
  bootstrap: [AppComponent]
})
export class AppModule { }

Now the Pipe is available to be used in components across the application.

The example below shows how to provide a Pipe globally in Angular. The Pipe being created will transform two separate words into one compound word. It will take a string value, then a parameter, that is used as a prefix to the string value.

<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/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>
<my-app></my-app>
<script type="typescript">
// app.js
const { Component, VERSION, Pipe, PipeTransform} = ng.core;

@Pipe({ name: 'prefix' })
class PrefixPipe implements PipeTransform {
  transform(value: string, prefix: string): string {
    if (prefix && value) {
      return `${prefix}-${value}`;
    }
    return value;
  }
}

@Component({
  selector: 'my-app',
  template: `
    <h2>{{ word }}</h2>
    <br />
    <h2>{{ word2 | prefix: 'long' }}</h2>
  `,
})
class AppComponent implements OnInit {
  word = 'two';
  word2 = 'term';

  constructor(private prefixPipe: PrefixPipe) {}

  ngOnInit(): void {
    this.word = this.prefixPipe.transform(this.word, 'seventy');
  }
}

// main.js
const { BrowserModule } = ng.platformBrowser;
const { NgModule } = ng.core;
const { CommonModule } = ng.common;

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

const { platformBrowserDynamic } = ng.platformBrowserDynamic;

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


P1: How to verify your solution is correct

✏️ Update the following spec files to include and provide the new Pipe:

  • src/app/order/order.component.spec.ts
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { of } from 'rxjs';
import { ItemTotalPipe } from '../item-total.pipe';
import { RestaurantService } from '../restaurant/restaurant.service';
import { MenuItemsComponent } from './menu-items/menu-items.component';
import { OrderComponent, OrderForm } from './order.component';
import { CreateOrderDto, OrderService } from './order.service';

class MockRestaurantService {
  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',
    });
  }
}

class MockOrderService {
  createOrder(order: CreateOrderDto) {
    return of({
      address: null,
      items: [
        { name: 'Onion fries', price: 15.99 },
        { name: 'Roasted Salmon', price: 23.99 },
      ],
      name: 'Jennifer Hungry',
      phone: null,
      restaurant: 'uPkA2jiZi24tCvXh',
      status: 'preparing',
      _id: '0awcHyo3iD6CpvhX',
    });
  }
}

const MockActivatedRoute = {
  snapshot: {
    paramMap: {
      get() {
        return 'poutine-palace';
      },
    },
  },
};

describe('OrderComponent', () => {
  let component: OrderComponent;
  let fixture: ComponentFixture<OrderComponent>;
  let orderService: OrderService;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [OrderComponent, MenuItemsComponent],
      imports: [ReactiveFormsModule, RouterTestingModule],
      providers: [
        {
          provide: RestaurantService,
          useClass: MockRestaurantService,
        },
        {
          provide: ActivatedRoute,
          useValue: MockActivatedRoute,
        },
        {
          provide: OrderService,
          useClass: MockOrderService,
        },
        ItemTotalPipe,
      ],
      schemas: [NO_ERRORS_SCHEMA],
    }).compileComponents();
    orderService = TestBed.inject(OrderService);
  });

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

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

  it('should get a restaurant based on route slug', () => {
    const mockRestaurant = {
      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',
    };
    expect(fixture.componentInstance.restaurant).toEqual(mockRestaurant);
  });

  it('should have an orderForm formGroup', () => {
    expect(
      fixture.componentInstance.orderForm?.controls['restaurant']
    ).toBeTruthy();
    expect(
      fixture.componentInstance.orderForm?.controls['address']
    ).toBeTruthy();
    expect(fixture.componentInstance.orderForm?.controls['phone']).toBeTruthy();
    expect(fixture.componentInstance.orderForm?.controls['items']).toBeTruthy();
  });

  it('should have a validator on items formControl', () => {
    const itemFormControl =
      fixture.componentInstance.orderForm?.controls['items'];
    expect(itemFormControl?.valid).toEqual(false);
  });

  it('should update items FormControl when setUpdatesItems is called', () => {
    fixture.detectChanges();
    const compiled = fixture.nativeElement as HTMLElement;
    const childInput = compiled
      .getElementsByTagName('pmo-menu-items')[0]
      .getElementsByTagName('input')[0];
    const formItems = fixture.componentInstance.orderForm?.get('items');
    childInput.click();
    fixture.detectChanges();
    expect(formItems?.value).toEqual([
      { name: 'Crab Pancakes with Sorrel Syrup', price: 35.99 },
    ]);
  });

  it('should update the order total when the items FormControl value changes', () => {
    fixture.detectChanges();
    const compiled = fixture.nativeElement as HTMLElement;
    const childInput1 = compiled
      .getElementsByTagName('pmo-menu-items')[0]
      .getElementsByTagName('input')[0];
    const childInput2 = compiled
      .getElementsByTagName('pmo-menu-items')[0]
      .getElementsByTagName('input')[1];
    childInput1.click();
    childInput2.click();
    fixture.detectChanges();
    const orderText = compiled.querySelector('.submit h4');
    expect(orderText?.textContent).toEqual('Total: $57.98');
  });

  it('should call the orderService createOrder on form submit with form values', () => {
    const createOrderSpy = spyOn(orderService, 'createOrder').and.callThrough();
    const expectedOrderValue: ReturnType<FormGroup<OrderForm>['getRawValue']> =
      {
        restaurant: '12345',
        name: 'Jennifer Hungry',
        address: '123 Main St',
        phone: '555-555-5555',
        items: [
          { name: 'Onion fries', price: 15.99 },
          { name: 'Roasted Salmon', price: 23.99 },
        ],
      };
    const compiled = fixture.nativeElement as HTMLElement;
    fixture.componentInstance.orderForm?.setValue(expectedOrderValue);
    fixture.detectChanges();
    (
      compiled.querySelector('button[type="submit"]') as HTMLButtonElement
    ).click();
    expect(createOrderSpy).toHaveBeenCalledWith(expectedOrderValue);
  });

  it('should show completed order when order is complete', () => {
    const expectedOrderValue: ReturnType<FormGroup<OrderForm>['getRawValue']> =
      {
        restaurant: '12345',
        name: 'Jennifer Hungry',
        address: '123 Main St',
        phone: '555-555-5555',
        items: [
          { name: 'Onion fries', price: 15.99 },
          { name: 'Roasted Salmon', price: 23.99 },
        ],
      };
    const compiled = fixture.nativeElement as HTMLElement;
    fixture.componentInstance.orderForm?.setValue(expectedOrderValue);
    fixture.detectChanges();
    (
      compiled.querySelector('button[type="submit"]') as HTMLButtonElement
    ).click();
    fixture.detectChanges();
    const displayedOrder = compiled.querySelector('h3');
    expect(displayedOrder?.textContent).toEqual(
      'Thanks for your order Jennifer Hungry!'
    );
  });

  it('should clear the form values when create new order is clicked', () => {
    const expectedOrderValue: ReturnType<FormGroup<OrderForm>['getRawValue']> =
      {
        restaurant: '12345',
        name: 'Jennifer Hungry',
        address: '123 Main St',
        phone: '555-555-5555',
        items: [
          { name: 'Onion fries', price: 15.99 },
          { name: 'Roasted Salmon', price: 23.99 },
        ],
      };
    const compiled = fixture.nativeElement as HTMLElement;
    fixture.componentInstance.orderForm?.setValue(expectedOrderValue);
    fixture.detectChanges();
    (
      compiled.querySelector('button[type="submit"]') as HTMLButtonElement
    ).click();
    fixture.detectChanges();
    (
      compiled.querySelector('button:nth-child(1)') as HTMLButtonElement
    ).click();
    const emptyform = {
      restaurant: '3ZOZyTY1LH26LnVw',
      name: '',
      address: '',
      phone: '',
      items: [],
    };
    expect(fixture.componentInstance.orderForm?.value).toEqual(emptyform);
  });
});

  • src/app/app.component.spec.ts
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 { ReactiveFormsModule } from '@angular/forms';
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 { ItemTotalPipe } from './item-total.pipe';
import { HistoryComponent } from './order/history/history.component';
import { OrderComponent } from './order/order.component';
import { DetailComponent } from './restaurant/detail/detail.component';
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, ReactiveFormsModule],
      declarations: [
        AppComponent,
        HomeComponent,
        RestaurantComponent,
        ImageUrlPipe,
        DetailComponent,
        OrderComponent,
        HistoryComponent,
        ItemTotalPipe,
      ],
      providers: [
        { provide: RestaurantService, useClass: MockRestaurantService },
        ItemTotalPipe,
      ],
      schemas: [NO_ERRORS_SCHEMA],
    })
      .overrideComponent(RestaurantComponent, {
        set: { template: '<p>I am a fake restaurant component</p>' },
      })
      .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 render the DetailComponent with router navigates to "/restaurants/slug" path', fakeAsync(() => {
    fixture.detectChanges();
    const compiled = fixture.nativeElement as HTMLElement;
    router.navigate(['restaurants/crab-shack']).then(() => {
      expect(location.path()).toBe('/restaurants/crab-shack');
      expect(compiled.querySelector('pmo-detail')).not.toBe(null);
    });
  }));

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

  it('should render the HistoryComponent with router navigates to "/order-history" path', fakeAsync(() => {
    fixture.detectChanges();
    const compiled = fixture.nativeElement as HTMLElement;
    router.navigate(['order-history']).then(() => {
      expect(location.path()).toBe('/order-history');
      expect(compiled.querySelector('pmo-history')).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();
  }));
});

  • src/app/order/history/history.component.spec.ts
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Observable, of } from 'rxjs';
import { ItemTotalPipe } from '../../item-total.pipe';
import { ListComponent } from '../list/list.component';
import { Order, OrderService } from '../order.service';

import { HistoryComponent } from './history.component';

class MockOrderService {
  getOrders(): Observable<{ data: Order[] }> {
    return of({
      data: [
        {
          address: '',
          items: [
            { name: 'Onion fries', price: 15.99 },
            { name: 'Roasted Salmon', price: 23.99 },
          ],
          name: 'Client 1',
          phone: '',
          restaurant: 'uPkA2jiZi24tCvXh',
          status: 'new',
          _id: '0awcHyo3iD6CpvhX',
        },
        {
          address: '',
          items: [
            { name: 'Steak Tacos', price: 15.99 },
            { name: 'Guacamole', price: 3.99 },
          ],
          name: 'Client 2',
          phone: '',
          restaurant: 'uPkA2jiZi24tCvXh',
          status: 'preparing',
          _id: '0awcHyo3iD6CpvhX',
        },
        {
          address: '',
          items: [
            { name: 'Mac & Cheese', price: 15.99 },
            { name: 'Grilled chicken', price: 23.99 },
          ],
          name: 'Client 3',
          phone: '',
          restaurant: 'uPkA2jiZi24tCvXh',
          status: 'delivery',
          _id: '0awcHyo8iD7XjahX',
        },
        {
          address: '',
          items: [
            { name: 'Eggrolls', price: 5.99 },
            { name: 'Fried Rice', price: 18.99 },
          ],
          name: 'Client 4',
          phone: '',
          restaurant: 'uPkA2jiZi24tCvXh',
          status: 'delivered',
          _id: '1awcJyo3iD6CpvhZ',
        },
      ],
    });
  }
}
describe('HistoryComponent', () => {
  let component: HistoryComponent;
  let fixture: ComponentFixture<HistoryComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [HistoryComponent, ListComponent, ItemTotalPipe],
      providers: [
        {
          provide: OrderService,
          useClass: MockOrderService,
        },
        ItemTotalPipe,
      ],
      schemas: [NO_ERRORS_SCHEMA],
    }).compileComponents();
  });

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

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

  it('should set response from getOrders service to orders member', () => {
    const expectedOrders: Order[] = [
      {
        address: '',
        items: [
          { name: 'Onion fries', price: 15.99 },
          { name: 'Roasted Salmon', price: 23.99 },
        ],
        name: 'Client 1',
        phone: '',
        restaurant: 'uPkA2jiZi24tCvXh',
        status: 'new',
        _id: '0awcHyo3iD6CpvhX',
      },
      {
        address: '',
        items: [
          { name: 'Steak Tacos', price: 15.99 },
          { name: 'Guacamole', price: 3.99 },
        ],
        name: 'Client 2',
        phone: '',
        restaurant: 'uPkA2jiZi24tCvXh',
        status: 'preparing',
        _id: '0awcHyo3iD6CpvhX',
      },
      {
        address: '',
        items: [
          { name: 'Mac & Cheese', price: 15.99 },
          { name: 'Grilled chicken', price: 23.99 },
        ],
        name: 'Client 3',
        phone: '',
        restaurant: 'uPkA2jiZi24tCvXh',
        status: 'delivery',
        _id: '0awcHyo8iD7XjahX',
      },
      {
        address: '',
        items: [
          { name: 'Eggrolls', price: 5.99 },
          { name: 'Fried Rice', price: 18.99 },
        ],
        name: 'Client 4',
        phone: '',
        restaurant: 'uPkA2jiZi24tCvXh',
        status: 'delivered',
        _id: '1awcJyo3iD6CpvhZ',
      },
    ];
    const orders = fixture.componentInstance.orders;
    expect(orders.value).toEqual(expectedOrders);
  });

  it('should display orders in UI', () => {
    const compiled = fixture.nativeElement as HTMLElement;
    const orderDivs = compiled.querySelectorAll(
      '.order:not(.header):not(.empty)'
    );
    expect(orderDivs.length).toEqual(4);
  });

  it('should display orders with appropriate classes', () => {
    const compiled = fixture.nativeElement as HTMLElement;
    const newOrder = compiled.getElementsByClassName('new');
    const preparingOrder = compiled.getElementsByClassName('preparing');
    const deliveryOrder = compiled.getElementsByClassName('delivery');
    const deliveredOrder = compiled.getElementsByClassName('delivered');
    expect(newOrder.length).toEqual(1);
    expect(preparingOrder.length).toEqual(1);
    expect(deliveryOrder.length).toEqual(1);
    expect(deliveredOrder.length).toEqual(1);
  });
});

  • src/app/order/list/list.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { ItemTotalPipe } from '../../item-total.pipe';
import { Order, OrderService } from '../order.service';

import { ListComponent } from './list.component';

class MockOrderService {
  updateOrder(order: Order, status: string) {
    return of({});
  }

  deleteOrder(orderId: string) {
    return of({});
  }
}

describe('ListComponent', () => {
  let component: ListComponent;
  let fixture: ComponentFixture<ListComponent>;
  let injectedOrderService: OrderService;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [ListComponent, ItemTotalPipe],
      providers: [
        {
          provide: OrderService,
          useClass: MockOrderService,
        },
        ItemTotalPipe,
      ],
    }).compileComponents();
    injectedOrderService = TestBed.inject(OrderService);
  });

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

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

  it('should display the order total', () => {
    fixture.componentInstance.orders = [
      {
        address: '',
        items: [
          { name: 'Onion fries', price: 15.99 },
          { name: 'Roasted Salmon', price: 23.99 },
        ],
        name: 'Client 1',
        phone: '',
        restaurant: 'uPkA2jiZi24tCvXh',
        status: 'new',
        _id: '0awcHyo3iD6CpvhX',
      },
    ];
    fixture.detectChanges();
    const compiled = fixture.nativeElement as HTMLElement;
    expect(compiled.querySelector('.total')?.textContent).toEqual('$39.98');
  });

  it('should call orderService updateOrder with order and action when "mark as" is clicked', () => {
    const updateOrderSpy = spyOn(
      injectedOrderService,
      'updateOrder'
    ).and.callThrough();
    const order1 = {
      address: '',
      items: [
        { name: 'Onion fries', price: 15.99 },
        { name: 'Roasted Salmon', price: 23.99 },
      ],
      name: 'Client 1',
      phone: '',
      restaurant: 'uPkA2jiZi24tCvXh',
      status: 'new',
      _id: '0awcHyo3iD6CpvhX',
    };
    fixture.componentInstance.orders = [order1];
    fixture.componentInstance.action = 'preparing';
    fixture.componentInstance.actionTitle = 'Preparing';
    fixture.detectChanges();
    const compiled = fixture.nativeElement as HTMLElement;
    const markAsLink = compiled.querySelector(
      '.actions .action button'
    ) as HTMLButtonElement;
    markAsLink?.click();
    expect(updateOrderSpy).toHaveBeenCalledWith(order1, 'preparing');
  });

  it('should call orderService deleteOrder with id when delete item is clicked', () => {
    const deleteOrderSpy = spyOn(
      injectedOrderService,
      'deleteOrder'
    ).and.callThrough();
    const order1 = {
      address: '',
      items: [
        { name: 'Onion fries', price: 15.99 },
        { name: 'Roasted Salmon', price: 23.99 },
      ],
      name: 'Client 1',
      phone: '',
      restaurant: 'uPkA2jiZi24tCvXh',
      status: 'new',
      _id: '0awcHyo3iD6CpvhX',
    };
    fixture.componentInstance.orders = [order1];
    fixture.detectChanges();
    const compiled = fixture.nativeElement as HTMLElement;
    const deleteLink = compiled.querySelector(
      '.actions .action button'
    ) as HTMLButtonElement;
    deleteLink.click();
    expect(deleteOrderSpy).toHaveBeenCalledWith('0awcHyo3iD6CpvhX');
  });
});

✏️ Update the spec file item-total.pipe.spec.ts to be:

import { ItemTotalPipe } from './item-total.pipe';
import { Item } from './order/order.service';

describe('ItemTotalPipe', () => {
  const mockItemList: Item[] = [
    { name: 'Truffle Noodles', price: 14.99 },
    { name: 'Garlic Fries', price: 15.99 },
  ];
  const pipe = new ItemTotalPipe();

  it('create an instance', () => {
    expect(pipe).toBeTruthy();
  });

  it('transform Item[] and return the sum of each item price, total price should be 30.98', () => {
    const total = pipe.transform(mockItemList);
    expect(total).toEqual(30.98);
  });

  it('expect empty array to be 0', () => {
    const total = pipe.transform([]);
    expect(total).toEqual(0);
  });
});

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

P1: Solution

Click to see the solution ✏️ Update src/app/item-total.pipe.ts to:

import { Pipe, PipeTransform } from '@angular/core';
import { Item } from './order/order.service';

@Pipe({
  name: 'itemTotal',
})
export class ItemTotalPipe implements PipeTransform {
  transform(items: Item[]): number {
    let itemTotal = 0.0;
    if (items.length) {
      for (const item of items) {
        itemTotal += item.price;
      }
      return Math.round(itemTotal * 100) / 100;
    }
    return itemTotal;
  }
}

✏️ Update src/app/order/order.component.ts to use the item total Pipe:

import { Component, OnInit, OnDestroy } from '@angular/core';
import {
  AbstractControl,
  FormBuilder,
  FormControl,
  FormGroup,
  ValidationErrors,
  ValidatorFn,
  Validators,
} from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { ItemTotalPipe } from '../item-total.pipe';
import { Restaurant } from '../restaurant/restaurant';
import { RestaurantService } from '../restaurant/restaurant.service';
import { Order, OrderService } from './order.service';

export interface Item {
  name: string;
  price: number;
}

export interface OrderForm {
  restaurant: FormControl<string>;
  name: FormControl<string>;
  address: FormControl<string>;
  phone: FormControl<string>;
  items: FormControl<Item[]>;
}

// CUSTOM VALIDATION FUNCTION TO ENSURE THAT THE ITEMS FORM VALUE CONTAINS AT LEAST ONE ITEM.
function minLengthArray(min: number): ValidatorFn {
  return (c: AbstractControl): ValidationErrors | null => {
    if (c.value.length >= min) {
      return null;
    }
    return { minLengthArray: { valid: false } };
  };
}

@Component({
  selector: 'pmo-order',
  templateUrl: './order.component.html',
  styleUrl: './order.component.css',
})
export class OrderComponent implements OnInit, OnDestroy {
  orderForm?: FormGroup<OrderForm>;
  restaurant?: Restaurant;
  isLoading = true;
  orderTotal = 0.0;
  completedOrder?: Order;
  orderComplete = false;
  orderProcessing = false;
  private onDestroy$ = new Subject<void>();

  constructor(
    private route: ActivatedRoute,
    private restaurantService: RestaurantService,
    private orderService: OrderService,
    private formBuilder: FormBuilder,
    private itemTotal: ItemTotalPipe
  ) {}

  ngOnInit(): void {
    // GETTING THE RESTAURANT FROM THE ROUTE SLUG
    const slug = this.route.snapshot.paramMap.get('slug');

    if (slug) {
      this.restaurantService
        .getRestaurant(slug)
        .pipe(takeUntil(this.onDestroy$))
        .subscribe((data: Restaurant) => {
          this.restaurant = data;
          this.isLoading = false;
          this.createOrderForm();
        });
    }
  }

  ngOnDestroy(): void {
    this.onDestroy$.next();
    this.onDestroy$.complete();
  }

  createOrderForm(): void {
    this.orderForm = this.formBuilder.nonNullable.group({
      restaurant: [this.restaurant?._id ?? '', Validators.required],
      name: ['', Validators.required],
      address: ['', Validators.required],
      phone: ['', Validators.required],
      // PASSING OUR CUSTOM VALIDATION FUNCTION TO THIS FORM CONTROL
      items: [[] as Item[], minLengthArray(1)],
    });
    this.onChanges();
  }

  getChange(newItems: Item[]): void {
    this.orderForm?.controls.items.patchValue(newItems);
  }

  onChanges(): void {
    // WHEN THE ITEMS CHANGE WE WANT TO CALCULATE A NEW TOTAL
    this.orderForm?.controls.items.valueChanges
      .pipe(takeUntil(this.onDestroy$))
      .subscribe((value) => {
        this.orderTotal = this.itemTotal.transform(value);
      });
  }

  onSubmit(): void {
    if (!this.orderForm?.valid) {
      return;
    }

    this.orderProcessing = true;
    this.orderService
      .createOrder(this.orderForm.getRawValue())
      .pipe(takeUntil(this.onDestroy$))
      .subscribe((res: Order) => {
        this.completedOrder = res;
        this.orderComplete = true;
        this.orderProcessing = false;
      });
  }

  startNewOrder(): void {
    this.orderComplete = false;
    this.completedOrder = undefined;
    this.orderTotal = 0.0;
    // CLEAR THE ORDER FORM
    this.createOrderForm();
  }
}

✏️ Update src/app/order/list/list.component.html to use the item total Pipe:

<h4>{{ listTitle }}</h4>
<ng-container *ngFor="let order of orders">
  <div [ngClass]="['order', order.status ? order.status : '']">
    <address>
      {{ order.name }} <br />{{ order.address }} <br />{{ order.phone }}
    </address>

    <div class="items">
      <ul>
        <li *ngFor="let item of order.items">{{ item.name }}</li>
      </ul>
    </div>

    <div class="total">${{ order.items | itemTotal }}</div>

    <div class="actions">
      <span class="badge">{{ statusTitle }}</span>
      <p class="action" *ngIf="action">
        Mark as:
        <button class="btn-link" (click)="markAs(order, action)">
          {{ actionTitle }}
        </button>
      </p>

      <p class="action">
        <button class="btn-link" (click)="deleteOrder(order._id)">
          Delete
        </button>
      </p>
    </div>
  </div>
</ng-container>

<div class="order empty">{{ emptyMessage }}</div>

Problem 2: Using Currency Pipe

Now that we have finished implementing our Item Total Pipe, this is a good time for us to talk about using Currency Pipe. You might have noticed that our prices are in USD, which is a currency, and is a perfect use for the Currency Pipe. Angular provides us with a Currency Pipe that formats a number as currency using locale rules. Locale Rules refers to a user’s country or region specific details like currency, language and more.

Add the Currency Pipe to the order.component.html , menu-items.component.html and list.component.html template files:

P2: What you need to know

How to:

  • Apply a Pipe (you previously learned this ✔️)

Like all the other Pipes we have created, the Currency Pipe can be used the exact same way.

P2: Solution

Click to see the solution ✏️ Update src/app/order/order.component.html to use the Currency Pipe:

<div class="order-form">
  <ng-container *ngIf="orderComplete && completedOrder; else showOrderForm">
    <h3>Thanks for your order {{ completedOrder.name }}!</h3>
    <div>
      <label class="control-label">
        Confirmation Number: {{ completedOrder._id }}</label
      >
    </div>

    <h4>Items ordered:</h4>
    <ul class="list-group panel">
      <li class="list-group-item" *ngFor="let item of completedOrder.items">
        <label>
          {{ item.name }}
          <span class="badge">{{ item.price | currency }}</span>
        </label>
      </li>

      <li class="list-group-item">
        <label>
          Total <span class="badge">{{ orderTotal | currency }}</span>
        </label>
      </li>
    </ul>

    <div>
      <label class="control-label"> Phone: {{ completedOrder.phone }} </label>
    </div>
    <div>
      <label class="control-label">
        Address: {{ completedOrder.address }}
      </label>
    </div>
    <p>
      <button (click)="startNewOrder()">Place another order</button>
    </p>
  </ng-container>
  <ng-template #showOrderForm>
    <h2>Order here</h2>
    <form
      *ngIf="orderForm && restaurant"
      [formGroup]="orderForm"
      (ngSubmit)="onSubmit()"
    >
      <tabset>
        <tab heading="Lunch Menu" *ngIf="restaurant.menu.lunch">
          <ul class="list-group">
            <pmo-menu-items
              [items]="restaurant.menu.lunch"
              formControlName="items"
            ></pmo-menu-items>
          </ul>
        </tab>
        <tab heading="Dinner Menu" *ngIf="restaurant.menu.dinner">
          <ul class="list-group">
            <pmo-menu-items
              [items]="restaurant.menu.dinner"
              formControlName="items"
            ></pmo-menu-items>
          </ul>
        </tab>
      </tabset>
      <div class="form-group">
        <label class="control-label">Name:</label>
        <input name="name" type="text" formControlName="name" />
        <p>Please enter your name.</p>
      </div>
      <div class="form-group">
        <label class="control-label">Address:</label>
        <input name="address" type="text" formControlName="address" />
        <p class="help-text">Please enter your address.</p>
      </div>
      <div class="form-group">
        <label class="control-label">Phone:</label>
        <input
          name="phone"
          type="text"
          pmoOnlyNumbers
          formControlName="phone"
        />
        <p class="help-text">Please enter your phone number.</p>
      </div>
      <div class="submit">
        <h4>Total: {{ orderTotal | currency }}</h4>
        <div class="loading" *ngIf="orderProcessing"></div>
        <button
          type="submit"
          [disabled]="!orderForm.valid || orderProcessing"
          class="btn"
        >
          Place My Order!
        </button>
      </div>
    </form>
  </ng-template>
</div>

✏️ Update src/app/order/list/list.component.html to use the currency Pipe:

<h4>{{ listTitle }}</h4>
<ng-container *ngFor="let order of orders">
  <div [ngClass]="['order', order.status ? order.status : '']">
    <address>
      {{ order.name }} <br />{{ order.address }} <br />{{ order.phone }}
    </address>

    <div class="items">
      <ul>
        <li *ngFor="let item of order.items">{{ item.name }}</li>
      </ul>
    </div>

    <div class="total">{{ order.items | itemTotal | currency }}</div>

    <div class="actions">
      <span class="badge">{{ statusTitle }}</span>
      <p class="action" *ngIf="action">
        Mark as:
        <button class="btn-link" (click)="markAs(order, action)">
          {{ actionTitle }}
        </button>
      </p>

      <p class="action">
        <button class="btn-link" (click)="deleteOrder(order._id)">
          Delete
        </button>
      </p>
    </div>
  </div>
</ng-container>

<div class="order empty">{{ emptyMessage }}</div>

✏️ Update src/app/order/menu-items/menu-items.component.html to use the Currency Pipe:

<li class="list-group-item" *ngFor="let item of items">
  <label>
    <input type="checkbox" (change)="updateItem(item)" />
    {{ item.name }} <span class="badge">{{ item.price | currency }}</span>
  </label>
</li>

Set Default Currency Code

If we want to display a different currency than the one provided by the application’s locale, which defaults to en-US and therefore USD, we may override the locale’s currency by providing DEFAULT_CURRENCY_CODE in app.module.ts.

✏️ Update app.module.ts to provide currency code:

import { HttpClientModule } from '@angular/common/http';
import { DEFAULT_CURRENCY_CODE, NgModule } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
import { BrowserModule } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { TabsModule } from 'ngx-bootstrap/tabs';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { HomeComponent } from './home/home.component';
import { RestaurantComponent } from './restaurant/restaurant.component';
import { ImageUrlPipe } from './image-url.pipe';
import { DetailComponent } from './restaurant/detail/detail.component';
import { OrderComponent } from './order/order.component';
import { MenuItemsComponent } from './order/menu-items/menu-items.component';
import { OnlyNumbersDirective } from './only-numbers.directive';
import { HistoryComponent } from './order/history/history.component';
import { ListComponent } from './order/list/list.component';
import { ItemTotalPipe } from './item-total.pipe';

@NgModule({
  declarations: [
    AppComponent,
    HomeComponent,
    RestaurantComponent,
    ImageUrlPipe,
    DetailComponent,
    OrderComponent,
    MenuItemsComponent,
    OnlyNumbersDirective,
    HistoryComponent,
    ListComponent,
    ItemTotalPipe
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    HttpClientModule,
    ReactiveFormsModule,
    BrowserAnimationsModule,
    TabsModule.forRoot()
  ],
  providers: [
    ItemTotalPipe,
    { provide: DEFAULT_CURRENCY_CODE, useValue: 'USD' },
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }