Angular and Storybook – Component with content projection

Reading Time: 3 minutes

Loading

In Bonnie’s visual DOM course, I learnt the technique of applying multiple content projections in a component.

I created a FoodMenuCardComponent with two content projections; i.e., a component with two <ng-content> elements. The first <ng-content> has selector ‘head’ that projects FoodQuestion component and the second <ng-content> has selector ‘body’ that projects a list of FoodChoice components.

The same result can be achieved without content projections but I want to practice the new concept and create a reusable card component that has head and body sections.

Create a food menu card component in food module

ng g c foodMenuCard --module=food
// food-menu-card.component.ts 
import { Component } from '@angular/core'

@Component({
  selector: 'app-food-menu-card',
  template: `
  <div>
    <ng-content select="[head]"></ng-content>
    <ng-content select="[body]"></ng-content>
  </div>`,
})
export class FoodMenuCardComponent {}

Create Storybook for Food Menu Card Component

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

// food-menu-card.storeis.ts

import { moduleMetadata } from '@storybook/angular'
// also exported from '@storybook/angular' if you can deal with breaking changes in 6.1
import { action } from '@storybook/addon-actions'
import { Story, Meta } from '@storybook/angular/types-6-0'
import { FoodChoiceComponent, FoodMenuCardComponent, FoodQuestionComponent } from '@/food'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'

export default {
  title: 'Food Menu Card',
  component: FoodMenuCardComponent,
  decorators: [
    moduleMetadata({
      imports: [ReactiveFormsModule, FormsModule],
      declarations: [FoodQuestionComponent, FoodChoiceComponent],
    }),
  ],
} as Meta

const FoodMenuCardTemplate: Story<FoodMenuCardComponent> = (args: FoodMenuCardComponent) => ({
  props: {
    ...args,
    foodChoiceAdded: action('foodChoiceAdded'),
  },
  template: `<app-food-menu-card>
    <app-food-question [question]="menuItem.question" head></app-food-question>
    <ng-container *ngFor="let choice of menuItem.choices; index as j; trackBy: choiceTrackByFn" body>
      <app-food-choice [choice]="choice" (foodChoiceAdded)="foodChoiceAdded($event)"></app-food-choice>
    </ng-container>
  </app-food-menu-card>`,
})

export const Primary = FoodMenuCardTemplate.bind({})
Primary.args = {
  menuItem: {
    id: '1',
    question: 'Do you wish to order dessert?',
    choices: [
      {
        id: 'd',
        name: 'Buffalo Chicken Wings',
        description: 'Spicy chicken wings',
        price: 8.99,
        available: true,
      },
    ],
  },
}

export const Soldout = FoodMenuCardTemplate.bind({})
Soldout.args = {
  menuItem: {
    id: '1',
    question: 'Do you wish to order dessert?',
    choices: [
      {
        id: 'a',
        name: 'Egg salad',
        description: 'Egg salad',
        price: 4.99,
        available: false,
      },
    ],
  },
}

To order to create story for component with content projection, my solution is to create template component for FoodChoiceMenuComponent

template: <app-food-menu-card>
    <app-food-question [question]="menuItem.question" head></app-food-question>
    <ng-container *ngFor="let choice of menuItem.choices; index as j; trackBy: choiceTrackByFn" body>
      <app-food-choice [choice]="choice" (foodChoiceAdded)="foodChoiceAdded($event)"></app-food-choice>
    </ng-container>
</app-food-menu-card>

<app-food-menu-card> is root element containing <app-food-question> and <ng-container> children. <app-root-question> has head attribute and replaces <ng-content select='[head]’> element whereas <ng-container> has body attribute and replaces <ng-content select='[body]’> element.

Two stories are created for FoodMenuCardComponent: Primary and Soldout. Primary renders question and available food choices whereas Soldout renders question and a sold out message.

Unfortunately, Primary story did not log action when Submit button was clicked. After googling, I found the solution in Github issue and Storybook examples repo. I had to pass addon action to props to listen to foodChoiceAdded event emitter of FoodChoiceComponent

props: {
    ...args,
    foodChoiceAdded: action('foodChoiceAdded'),
}

Property name is foodChoiceAdded that is the name of the event emitter. Action name is ‘foodChoiceAdded’ but it can be arbitrary text such as action(‘log’). When button is clicked, Storybook Actions tab logs ‘foodChoiceAdded’ name and its data.

Start storybook application

npm run storybook

Click the title Food Menu Card -> Primary and the component is rendered with a question and once choice

When quantity is updated and submitted, event is emitted and logged on Actions tab

Click the title Food Menu Card -> Soldout and the component is rendered with a question and a sold out message.

This is the end of the blog post and we will keep you posted after progress is made, thanks.

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
  3. Angular Storybook Netlify App: https://storybooks-angular.netlify.app/?path=/story/custom-style–default
  4. Angular Storybook Examples: https://github.com/storybookjs/storybook/blob/next/examples/angular-cli/src/stories/core/styles/story-styles.stories.ts
  5. Action Logger does not handle event with template prop: https://github.com/storybookjs/storybook/issues/4820