Catalin Ciubotaru avatar

How to add Storybook and Chromatic to your Angular monorepo

Storybook is an amazing tool, and you should use it if you’re thinking about starting a reusable component library for your project(s).

The Problem

All apps grow(if they’re successful) and quite often this means that it gets easier and easier for the code to become unmanageable. One of the parts that usually becomes a big ball of mud is the reusable component part.

While adding a new feature, you realize that you’re re-creating the same button for the 5th time. Same CSS, same HTML.

Good to know before jumping in

This article assumes some familiarity with Angular and Github Actions.

I’m not affiliated with StoryBook in any shape or form. This is my experience so far.

The Solution

Start a component library. Our library should fulfil the following requirements 👇

  1. Easy to maintain
  2. Easy to add new components to it
  3. Easy to visualize
  4. Easy to play with component inputs
  5. Bonus: Easy to test accessibility
  6. Bonus: Easy to test interactivity
  7. Bonus: Easy to do visual regression testing

Wait! Before you jump in and starting building all of this yourself( like any developer would do), have a look at Storybook. It seems to be a tool that ticks ✅ all of the boxes above. Let’s see how it performs and how easy it is to integrate in our Angular monorepo-ish.

Part 1 - Setup Storybook

This is where our journey starts. Our project structure looks like this right now:

Folder structure of the current project

In our monorepo, we have 2 folders: one for apps and one for projects. (environments is purely for environment based configuration)

The 📁web folder is where our web client lives. The 📁business-logic folder is where our reusable logic lives(unimportant for this article). The 📁ui folder is where our reusable components live and the 📁ui-showcase is our old, hand-made, pain in the 🍑, difficult to maintain component library visualizer.

I tried to run npx storybook init (the easy way 😅). But the command failed...partially...and created some files here and there. Spent a bit of time to figure out what happened, but in the end I decided to go the manual way, to also understand better what is actually happening under the hood.

So, with this in mind, I started looking at some example repos, and search a bit through Github on how other people are doing this.

The .storybook folder

Here’s the first required part. You need a 📁.storybook folder where your Storybook configuration will live. Here’s the main.js file 👇️

module.exports = {
stories: ['../projects/ui/**/*.stories.mdx', '../projects/ui/**/*.stories.@(js|jsx|ts|tsx)'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions'
],
framework: '@storybook/angular',
core: {
builder: '@storybook/builder-webpack5'
}
};

We’re basically telling Storybook

  • where to look for stories(I decided to colocate the stories with the reusable components, for easier maintainability)
  • which add-ons to load(I use the default ones)
  • which framework we’re using(Angular in this case)
  • which builder should it use (webpack 5 in this case)

Next is the preview.js file 👇️

import { setCompodocJson } from '@storybook/addon-docs/angular';

import docJson from '../documentation.json';

setCompodocJson(docJson);

export const parameters = {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/
}
},
docs: { inlineStories: true }
};

To my understanding, this bridges the communication between what compodoc generates and StoryBook. More on compodoc in a bit.


Next is the Typescript configuration in a tsconfig.json file 👇️

{
"extends": "../tsconfig.json",
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"baseUrl": "./",
"paths": {
"@env/*": ["../environments/*"],
"business-logic": ["../projects/business-logic/src"]
}
},
"include": ["../projects/ui/src/lib/**/*.ts", "../projects/business-logic/src/lib/**/*.ts"],
"exclude": [
"../projects/business-logic/src/lib/**/*.spec.ts"
],
"files": ["./typings.d.ts"]
}

Here we’re telling the Typescript compiler which folders to scan(📁ui and 📁business-logic in our case). Ideally all models would be defined in the 📁ui folder, but right now, that's not the case. This is why we have to reference stuff from the 📁business-logic folder.

We also setup aliases for the 📁environments folder and for the 📁business-logic folder. This cleans-up our imports.

Last but not least, we exclude files that we don’t care about(.spec files in this case).


And here’s the typings.d.ts 👇️

declare module '*.md' {
const content: string;
export default content;
}

This adds typings to .md imports. Not sure why this is needed.

The angular.json file

We also need to change our angular configuration to add a new application. This is what I had to add 👇️

"storybook": {
"projectType": "application",
"root": "projects/ui",
"sourceRoot": "projects/ui",
"architect": {
"build": {
"options": {
"tsConfig": ".storybook/tsconfig.json",
"styles": ["projects/ui/src/lib/styles.scss"],
"scripts": []
}
}
}
},

This adds a new project to the angular-cli, tells which .tsconfig to use and loads the root styles needed throughout the 📁ui folder.

The package.json file

There are a couple of dev dependencies that need installing 👇️

{
"@babel/core": "7.20.5",
"@compodoc/compodoc": "1.1.19",
"@storybook/addon-actions": "6.5.14",
"@storybook/addon-essentials": "6.5.14",
"@storybook/addon-interactions": "6.5.14",
"@storybook/addon-links": "6.5.14",
"@storybook/angular": "6.5.14",
"@storybook/builder-webpack5": "6.5.14",
"@storybook/manager-webpack5": "6.5.14",
"@storybook/testing-library": "0.0.13",
"babel-loader": "8.3.0",
"eslint-plugin-storybook": "0.6.8"
}

On top of this, there are a few new commands we’ll need 👇️

{
"docs:json": "compodoc -p .storybook/tsconfig.json -e json -d .",
"storybook": "npm run docs:json && start-storybook -p 6006",
"build-storybook": "npm run docs:json && build-storybook"
}

These commands will help you run Storybook locally with npm run storybook and build for releasing with npm run build-storybook .

The tsconfig.app.json file

This is the configuration for out client(our 📁web folder in our case). In here, we have to tell the typescript compiler to ignore *.stories.ts files. This can be done by adding this: "exclude": ["**/*.stories.ts”] . That's it.

The .eslintrc.json file

As a tip, it’s worth adding the "plugin:storybook/recommended” to the extends part of your eslint configuration for some smarter linting. This will only care about linting Storybook stories(*.stories.ts files)

Part 2 - Our first reusable component

Just to see it working, here’s my ButtonComponent . It's a standalone component and the contents of it are not super important. We just need a component that we want to see in action.

button.component.ts 👇️

import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';

import { ButtonSize, PossibleButtonSizes, ButtonType } from '../models/buttons';

@Component({
standalone: true,
selector: 'lib-button',
template: `<button
class="lib-button"
[style]="cssVariables"
[class]="type"
[disabled]="disabled"
(click)="onClick()">
{{ title }}
</button>`,
styleUrls: ['./button.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ButtonComponent {
@Input() title: string;
@Input() type: ButtonType;
@Input() size: ButtonSize = 'default';
@Input() disabled = false;

@Output()
buttonClicked = new EventEmitter<Event>();

protected get cssVariables(): string {
return `
--fontSize: \${PossibleButtonSizes[this.size].fontSize};
--padding: \${PossibleButtonSizes[this.size].padding};
`;
}

protected onClick(): void {
this.buttonClicked.emit();
}
}

buttons.ts 👇️

export const PossibleButtonSizes: ButtonSizes = {
small: {
padding: '6px 22px',
fontSize: 16 / 16 + 'rem'
},
default: {
padding: '10px 30px',
fontSize: 18 / 16 + 'rem'
},
large: {
padding: '12px 38px',
fontSize: 21 / 16 + 'rem'
}
};

export type ButtonSize = 'small' | 'default' | 'large';
export type ButtonType = 'primary' | 'secondary' | 'tertiary' | 'danger';
export type ButtonSizes = {
[key in ButtonSize]: {
padding: string;
fontSize: string;
};
};

button.component.scss 👇️

@import '../theming/settings/variables/load';

.snappcar-button {
padding: var(--padding);
border-radius: 8px;
border: none;
font-size: var(--fontSize);
font-weight: 600;
color: white;
border: 1px solid transparent;
transition: color 0.2s ease, background-color 0.2s ease;

&:focus {
outline-offset: 4px;
outline-color: $primary-blue;
}

&.primary:not(:disabled) {
background-color: $primary-orange;

&:focus {
outline-color: $primary-orange;
}
&:hover {
background-color: $orange-accent-3;
}
&:active {
background-color: $orange-accent-2;
}
}

&.secondary:not(:disabled) {
background-color: $primary-blue;

&:hover {
background-color: $blue-accent-2;
}
&:active {
background-color: $blue-accent-1;
}
}

&.tertiary:not(:disabled) {
background-color: transparent;
color: $primary-blue;
border: 1px solid currentColor;

&:hover {
background-color: $blue-accent-8;
}
&:active {
background-color: $blue-accent-7;
}
}

&.danger:not(:disabled) {
background-color: $primary-red;

&:focus {
outline-color: $primary-red;
}

&:hover {
background-color: $red-accent-2;
}
&:active {
background-color: $red-accent-1;
}
}

&:disabled {
color: $gray-4;
background-color: $gray-6;
cursor: not-allowed;
}
}

Again, this is not super important. You can use whatever component you want here. A component that you want to reuse.

Important though is to have a .stories.ts file next to it. This is where your stories will be defined, so Storybook knows what to do with it.

button.stories.ts 👇️

import { Meta, Story } from '@storybook/angular';

import { ButtonComponent } from './button.component';

export default {
component: ButtonComponent,
excludeStories: /.*Data$/
} as Meta;

const Template: Story = (args) => ({
props: {
size: 'default',
...args
}
});

export const Primary = Template.bind({});
Primary.args = {
title: 'Primary button',
type: 'primary',
disabled: false
};

export const Secondary = Template.bind({});
Secondary.args = {
title: 'Secondary button',
type: 'secondary',
disabled: false
};

export const Tertiary = Template.bind({});
Tertiary.args = {
title: 'Tertiary button',
type: 'tertiary',
disabled: false
};

export const Danger = Template.bind({});
Danger.args = {
title: 'Danger button',
type: 'danger',
disabled: false
};

Here we created 4 stories, to show our button with 4 different configurations. Again, for more info on how to create stories, the Storybook Documentation is quite good.

Now, if you run npm run storybook this is what you'll get 👇️

The UI of running Storybook locally

You can see our stories, all their inputs and you can play with all of that, change the text and see how our button will look/behave in different situations.

This is awesome! Basically, we are done fulfilling the first 4 points of our list.

Part 3 - Bonus stuff

Generic wrapper for all our components

We notice that the component is really up there, on the top left corner of our canvas. That’s not very pretty. We can easily setup a wrapper that will be used on all component stories and that wrapper will add some spacing. Here the new and improved preview.js on the 📁.storybook folder 👇️

import { setCompodocJson } from '@storybook/addon-docs/angular';
import { componentWrapperDecorator } from '@storybook/angular';

import docJson from '../documentation.json';

export const decorators = [
componentWrapperDecorator((story) => `<div style="margin: 1rem">\${story}</div>`)
];

setCompodocJson(docJson);

export const parameters = {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/
}
},
docs: { inlineStories: true }
};

Add accessibility check

We can easily add an add-on that will flag if a component has accessibility issues. We can do this by installing @storybook/addon-a11y and adding that to the array of add-ons in the main.js file in the 📁.storybook folder. Now we can see that one of our buttons has an a11y issue 👇️

The results of the accessiblity check in the browser

Add mock action handlers

We can also add a mock handler for events, to see when they happened. This is done by slightly changing our Template variable in the button.stories.ts file 👇️

import { action } from '@storybook/addon-actions';

const actionsData = {
buttonClicked: action('buttonClicked')
};

const Template: Story = (args) => ({
props: {
...args,
buttonClicked: actionsData.buttonClicked
}
});

After this, every time we click the button, we can see a log 👇️

The Interactions tab on the Browser, showing event handlers

Interaction tests

Adding an interaction test is also very simple. First, we need to install @storybook/test-runner .

Afterwards, we need a new command in package.json . Mine looks like this: "test-storybook": "test-storybook” .

Last but not least, we need to add the interaction in our stories. I added a simple interaction that tries to click the PrimaryButton in the buttons.stories.ts file 👇️

Primary.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Simulates clicking the button
await fireEvent.click(canvas.getByText('Primary button'));
};

Visual regression testing

This is a cool one! Storybook works great with Chromatic. By integrating these two, you can publish your Storybook library, you can visualize your builds and... 🥁🥁🥁 You get alerted when there's a visual regression 🎉.

In order to add it you need a few things:

  1. An account(has a very generous free tier)
  2. Install the chromatic package
  3. Add a new command in your package.json. Mine looks like this: "chromatic": "npx chromatic --project-token={my-project-id}”
  4. Add a new Github Action. My /.github/workflows/chromatic.yml file looks like this 👇️
# Workflow name
name: 'Chromatic Deployment'

# Event for the workflow
on:
pull_request:
branches: [develop]

# List of jobs
jobs:
test:
# Operating System
runs-on: ubuntu-latest
# Job steps
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
fetch-depth: 0 # 👈 Required to retrieve git history
- name: Install dependencies
run: npm ci --legacy-peer-deps
- name: Publish to Chromatic
uses: chromaui/action@v1
with:
projectToken: \${{ secrets.CHROMATIC_PROJECT_TOKEN }}
exitOnceUploaded: true

This is just what works for me. For you, it could be completely different.

If you change the text color for the button and push, now you’ll see that the build needs approval 👇️

The UI on Chromatic, showing that there are approvals needed

And you’ll see that what the difference is 👇️

The UI on Chromatic, showing the visual regression in the current PR

Part 4 - Congrats

If you made it this far, congrats. This was a long one!

Want more?

If you want to know more about this, or something doesn't make any sense, please let me know.

Also, if you have questions, you know where to find me… On the internet!

Be kind to each other! 🧡

Over and out

My Twitter avatar
Catalin Ciubotaru 🚀

How to add @Storybookjs and @chromaui to your #Angular repo. https://catalincodes.com/posts/how-to-add-storybook-to-angular #StoryBook #Chromatic

Dec 19, 2022
39 people are talking about this

Wanna read more?

Updates delivered to your inbox!

A periodic update about my life, recent blog posts, how-tos, and discoveries.

No spam - unsubscribe at any time!