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
}
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:
- Repo: https://github.com/railsstudent/ng-spanish-menu
- Storybook: Build a simple component: https://storybook.js.org/tutorials/intro-to-storybook/angular/en/simple-component/
- Actions: https://storybook.js.org/docs/angular/essentials/actions