Angular and Storybook – Simple Component with inputs and actions

 27 total views,  2 views today

Previously, I created a story book for simple component with inputs only (link) . In this post, I want to add new stories for a simple components with both input and action.

The component is FoodChoiceComponent that has two states: choice available to order and choice that has sold out.

If choice is available, user can input quantity and submit the reactive form. Otherwise, the component hides the form and displays a message, “Sorry, this choice is sold out”.

Create a food choice component in food module

ng g c foodChoice --module=food

Food choice component is a simple presentational component that is consisted of name, description, price, quantity and a reactive form.

// food-choice.component.ts 

// for the sakes of brevity, import statements are omitted

@Component({
  selector: 'app-food-choice',
  templateUrl: './food-choice.component.html',
  styleUrls: ['./food-choice.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FoodChoiceComponent implements OnInit, OnDestroy {
  @Input()
  choice: Choice

  @Output()
  foodChoiceAdded = new EventEmitter<OrderedFoodChoice>()

  submitChoice$ = new Subject<Event>()
  unsubscribe$ = new Subject<boolean>()
  processing = false

  form = this.fb.group({
    quantity: new FormControl(1, [Validators.required, Validators.min(1)]),
  })

  constructor(private fb: FormBuilder) {}

  ngOnInit(): void {
    this.submitChoice$
      .pipe(
        tap(($event) => {
          $event.preventDefault()
          $event.stopPropagation()
          this.processing = true
        }),
        delay(1000),
        map(() => ({
          ...this.form.value,
          name: this.choice.name,
          description: this.choice.description,
          price: this.choice.price,
        })),
        tap(() => (this.processing = false)),
        takeUntil(this.unsubscribe$),
      )
      .subscribe((value) => this.foodChoiceAdded.emit(value))
  }

  get quantity() {
    return this.form.get('quantity') as FormControl
  }

  ngOnDestroy(): void {
    this.unsubscribe$.next(true)
    this.unsubscribe$.complete()
  }
}
// food-choice.component.html

<div class="container">
  ...
  <ng-container [ngSwitch]="choice.available">
    <form [formGroup]="form" (ngSubmit)="submitChoice$.next($event)" *ngSwitchCase="true">
      <label name="quantity" class="item">
        <span class="field">Quantity: </span>
        <input type="number" formControlName="quantity" />
      </label>
      <div *ngIf="quantity.invalid && (quantity.touched || quantity.dirty)" class="alert alert-danger">
        <div *ngIf="quantity.errors?.min" class="error">Quantity must be at least {{ quantity.errors?.min?.min }}.</div>
      </div>
      <div *ngIf="processing" class="alert">
        <div class="processing">Loading...</div>
      </div>
      <button type="submit" [disabled]="processing || form.value.quantity <= 0">Submit</button>
    </form>
    <ng-container *ngSwitchDefault>
      <ng-container *ngTemplateOutlet="soldout; context: { choice: choice }"> </ng-container>
    </ng-container>
  </ng-container>
  <ng-template #soldout let-choice="choice">
    <span class="item sold-out">Sorry, {{ choice.name }} is sold out</span>
  </ng-template>
</div>

Input is a Choice interface that has several properties

export interface Choice {
  id: string
  name: string
  description: string
  price: number
  available: boolean
}
If available is true, reactive form is rendered.
If available is false, a message is displayed to indicate that the choice is sold out.

The food choice component has an event emitter that emits the user’s choice to container component. When user submits the reactive form, the submit event notifies RxJS subject to process the data and to emit the result to container component.

<form [formGroup]="form" (ngSubmit)="submitChoice$.next($event)" *ngIf="choice.available; else soldout">
....
</form>
submitChoice$ = new Subject<Event>()

@Output()
foodChoiceAdded = new EventEmitter<OrderedFoodChoice>()

form = this.fb.group({
  quantity: new FormControl(1, [Validators.required, Validators.min(1)]),
})

ngOnInit(): void {
    this.submitChoice$
      .pipe(
        tap(($event) => {
          $event.preventDefault()
          $event.stopPropagation()
          this.processing = true
        }),
        delay(1000),
        map(() => ({
          ...this.form.value,
          name: this.choice.name,
          description: this.choice.description,
          price: this.choice.price,
        })),
        tap(() => (this.processing = false)),
        takeUntil(this.unsubscribe$),
      )
      .subscribe((value) => this.foodChoiceAdded.emit(value))
}

Create Storybook for Food Choice Component

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

// food-choice.storeis.ts
import { moduleMetadata } from '@storybook/angular'
// also exported from '@storybook/angular' if you can deal with breaking changes in 6.1
import { Story, Meta } from '@storybook/angular/types-6-0'
import { FoodChoiceComponent } from '@/food'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'

export default {
  title: 'Food Choice',
  component: FoodChoiceComponent,
  decorators: [
    moduleMetadata({
      imports: [ReactiveFormsModule, FormsModule],
    }),
  ],
  argTypes: { onClick: { action: 'clicked' } },
} as Meta

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

export const Primary = Template.bind({})
Primary.args = {
  choice: {
    id: '1',
    name: 'Vino tinto',
    description: 'Red wine',
    price: 12.99,
    available: true,
  },
}

export const Soldout = Template.bind({})
Soldout.args = {
  choice: {
    id: '1',
    name: 'Vino tinto',
    description: 'Red wine',
    price: 12.99,
    available: false,
  },
}

Two stories are created for FoodChoiceComponent, Primary and Soldout. Primary renders that state of an available food choice while Soldout renders the state of a sold out food choice.

Start storybook application

npm run storybook

Click the title Food Choice -> Primary and the component is rendered with reactive form

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

Click the title Food Choice -> Soldout and the component is rendered with 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: Build a simple component: https://storybook.js.org/tutorials/intro-to-storybook/angular/en/simple-component/
  3. Actions: https://storybook.js.org/docs/angular/essentials/actions