Angular and Storybook – Mock Data in Component

Reading Time: 5 minutes

Loading

Introduction

After building a few presentational components, I am ready to build a container component, food menu component, with them. Food menu component is responsible for displaying an option dropdown and a list of menu items.

This is the component tree of food menu component.

The functions of food menu component are to retrieve menu data from Netlify function, listen to selected option from the dropdown, filter the data and eventually pass it to food menu card to display.

Visualizing container component in Storybook involves mock data because we don’t want to pull data from real data sources. Angular makes it easy because Angular provides dependency injection to inject mock service to provide fake data.

The process is similar to mock data in Angular unit testing except the result is seen visually and I am going to show the readers how to do it.

Create Food menu component in food module

ng g c foodMenu --module=food
// food-menu.component.ts 
... omit import statement ...

import { Choice, MenuItem, OrderedFoodChoice } from '../interfaces'
import { FoodService } from '../services'

@Component({
  selector: 'app-food-menu',
  templateUrl: './food-menu.component.html',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FoodMenuComponent implements OnInit, OnDestroy {
  @Output()
  addDynamicFoodChoice = new EventEmitter<OrderedFoodChoice>()

  menuItems$: Observable<MenuItem[] | undefined>
  handleFoodChoiceSub$ = new Subject<OrderedFoodChoice>()
  menuOptionSub$ = new BehaviorSubject<string>(MenuOptions.all)
  unsubscribe$ = new Subject<boolean>()

  qtyMap: Record<string, number> | undefined

  constructor(private service: FoodService) {}

  ngOnInit(): void {
    const menuUrl = `${environment.baseUrl}/menu`

    this.menuItems$ = combineLatest([
      this.service.getFood(menuUrl),
      this.menuOptionSub$,
      this.service.quantityAvailableMap$,
    ]).pipe(
      map(([menuItems, option]) => ({
        menuItems,
        option,
      })),
      map(({ menuItems, option }) => this.filterMenuItems(menuItems, option)),
      takeUntil(this.unsubscribe$),
    )

    this.service.quantityAvailableMap$.pipe(takeUntil(this.unsubscribe$)).subscribe((updatedQtyMap) => {
      if (!updatedQtyMap) {
        this.qtyMap = undefined
      } else {
        this.qtyMap = {
          ...updatedQtyMap,
        }
      }
    })

    this.handleFoodChoiceSub$
      .pipe(
        tap(({ id, quantity }) => this.service.updateQuantity(id, quantity)),
        takeUntil(this.unsubscribe$),
      )
      .subscribe((choice) => this.addDynamicFoodChoice.emit(choice))
  }

  .. omit trivial logic ...

  filterMenuItems(menuItems: MenuItem[] | undefined, option: string): MenuItem[] | undefined {
    ... return filtered menu item ...
  }
}

FoodMenuComponent injects FoodService that retrieves data from Netlify function and calculates remaining quantity of the menu items. Furthermore, the html template resolves the observable, this.menuItem$, to render the filtered menu items in the presentational components.

Set up mock service and data

Rather than using FoodService in Storybook, we create a mock service, MockFoodService, and implement the same functions that exist in FoodService.

// mock.ts

export class MockFoodService {
  private quantityAvailableSub$ = new BehaviorSubject<Record<string, number> | undefined>(undefined)
  quantityAvailableMap$ = this.quantityAvailableSub$.asObservable()

  constructor(private menuItems?: MenuItem[]) {}

  private buildQtyMap(): Record<string, number> | undefined {
    if (!this.menuItems) {
      return undefined
    }
    return this.menuItems.reduce((acc, mi) => {
      mi.choices.forEach(({ id, quantity }) => {
        acc[id] = quantity
      })
      return acc
    }, {} as Record<string, number>)
  }

  getFood(): Observable<MenuItem[] | undefined> {
    const qtyMap = this.buildQtyMap()
    this.quantityAvailableSub$.next(qtyMap)
    return of(this.menuItems)
  }

  updateQuantity(id: string, quantity: number): void {
    const qtyAvailableMap = this.quantityAvailableSub$.getValue()
    if (qtyAvailableMap) {
      const oldQty = qtyAvailableMap[id]
      const nextQty = oldQty - quantity
      if (nextQty >= 0) {
        this.quantityAvailableSub$.next({
          ...qtyAvailableMap,
          [id]: nextQty,
        })
      }
    }
  }

  private getLatestQtyMap() {
    const qtyAvailableMap = this.quantityAvailableSub$.getValue()
    if (!qtyAvailableMap) {
      const qtyMap = this.buildQtyMap()
      this.quantityAvailableSub$.next(qtyMap)
    }
    return this.quantityAvailableSub$.getValue()
  }

  getQuantity(id: string): number {
    const qtyAvailableMap = this.getLatestQtyMap()
    if (qtyAvailableMap) {
      return qtyAvailableMap[id] || 0
    }
    return 0
  }
}

The constructor of MockFoodService expects MenuItem array; therefore, we prepare static mock data in a separate file.

// constants.ts

export const MockData: MenuItem[] = [
  {
    id: '1',
    question: 'Which appetizer(s) do you wish to order?',
    choices: [
      {
        id: 'a',
        name: 'Egg salad',
        quantity: 10,
        ... other properties ...
      },
      {
        id: 'd',
        name: 'Buffalo Chicken Wings',
        quantity: 10,
        ... other properties ...
      },
      {
        id: 'b',
        name: 'Oven Baked Zucchini Chips',
        quantity: 10,
        ... other properties ...
      },
    ],
  },
  {
    id: '2',
    question: 'Which dessert(s) do you wish to order?',
    choices: [
      {
        id: 'a1',
        name: 'Ice cream',
        quantity: 10,
        ... other properties ...
      },
      {
        id: 'b1',
        name: 'Tiramisu',
        quantity: 10,
        ... other properties ...
      },
    ],
  },
]

Mock service and data is in place and we can finally add new storybook to visualize the food menu.

Add Storybook for Food Menu Component

Create food-menu.stories.ts under the food-menu folder

// food-menu.storeis.ts

import { HttpClientModule } from '@angular/common/http'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { Meta, moduleMetadata, Story } from '@storybook/angular'

import { FoodChoiceComponent } from '../food-choice'
import { FoodChoiceFormComponent } from '../food-choice-form'
import { FoodMenuCardComponent } from '../food-menu-card'
import { FoodMenuOptionComponent } from '../food-menu-option'
import { FoodQuestionComponent } from '../food-question'
import { FoodService } from '../services'
import { MockData, MockFoodService, SoldOutMockData } from '../storybook-mock'
import { FoodMenuComponent } from './food-menu.component'

export default {
  title: 'Food Menu',
  component: FoodMenuComponent,
  decorators: [
    moduleMetadata({
      declarations: [
        FoodChoiceComponent,
        FoodQuestionComponent,
        FoodChoiceFormComponent,
        FoodMenuCardComponent,
        FoodMenuOptionComponent,
      ],
      imports: [ReactiveFormsModule, FormsModule, HttpClientModule],
      providers: [
        {
          provide: FoodService,
          useFactory: () => new MockFoodService(MockData),
        },
      ],
    }),
  ],
  argTypes: { onClick: { action: 'clicked' } },
} as Meta

const Template: Story<FoodMenuComponent> = (args: FoodMenuComponent) => ({
  props: args,
})

export const Menu = Template.bind({})

The magic of the storybook is the providers array of moduleMetadata where we use useFactory to inject an instance of MockFoodService for FoodService.

providers: [
  {
      provide: FoodService,
      useFactory: () => new MockFoodService(MockData),
   },
],

When Storybook invokes this.service.getFood() and this.service.quantityAvailableMap$ in ngOnInit() of FoodMenuComponent, it actually invokes the implementation defined in MockFoodService. Therefore, there is no dependency between Storybook and Netlify function.

After laying down all the hard work, we can launch the storybook application and see the new story in action.

Start storybook application

npm run storybook

Click the title Food Menu -> Menu and view the dropdown, menu questions and items.

When dropdown value is “Show all”, the menu displays all questions and choices.

Next, I select “Show sold out” in the dropdown and the menu displays an appetizer that is no longer served.

Last, I select “Show available only” in the dropdown and the menu displays appetizers and desserts that have positive quantity.

Final thought

Storybook has the ability to inject mock service for container component. Story loads fake data from mock service on behalf of the container component and pass it down to presentational components to display. Then, we can interact with components to confirm the expected behavior in the story. When the results are satisfactory, we can even publish the new story to Chromatic for collaboration (refer to “Angular and Storybook – Publish to Chromatic”).

This is the end of the blog post and I hope you like the content and continue to follow my learning experience in Angular, Storybook and other web technologies.

Resources:

  1. Repo: https://github.com/railsstudent/ng-spanish-menu
  2. Storybook: Create a template component: https://storybook.js.org/docs/angular/workflows/stories-for-multiple-components#creating-a-template-component