Using Storybook with Vue single file components
Tags: vue, storybook, createweekly
Published: 2020-01-08 10:03:28

[Storybook](https://storybook.js.org/) is a great tool for designing, writing and testing components in isolation. With it, we can create and test components without needing to set up all the code and business logic that surround them in a real app. The Storybook workflow of focusing on developing one component at a time also helps with encapsulation and modularity — it is less likely we will develop a component that is coupled to other parts of the app if we develop each component in isolation.

Storybook started life as a tool for developing React components, but it now has great support for many other UI frameworks, including Vue. Setting up Storybook with Vue is easy, and with Vue's single-file components (SFCs), we can write stories that keep associated template, logic, and styles co-located in the same file, while being able to view the source in the Storybook UI.

# Setting up a Vue CLI project with Storybook

Let's use Vue CLI to setup a simple project with Storybook.

- Install the latest version of Vue CLI

  ```sh
  npm install -g @vue/cli
  ```

- Create a new project with the default preset

  ```sh
  vue create vue-storybook-tutorial --default
  ```

- Change into the newly created project directory and install Storybook

  ```sh
  cd vue-storybook-tutorial
  npx -p @storybook/cli sb init --type vue
  ```

# Writing a simple Button component

Let's now create a simple button component to write stories for. The component has one prop, `color` which takes the value of either `normal` (the default), or `primary`.

```html
<template>
    <button class="button" :class="`button-color--${color}`">
        <slot></slot>
    </button>
</template>

<script>
export default {
    name: 'Button',
    props: {
        color: {
            type: String,
            default: 'normal', // 'normal' or 'primary'
        },
    },
};
</script>

<style scoped>
.button {
    appearance: none;
    border: none;
    font-family: sans-serif;
    padding: 8px 16px;
    border-radius: 2px;
}

.button-color--normal {
    background-color: #eee;
    color: #222;
}

.button-color--normal:hover,
.button-color--normal:focus {
    background-color: #e0e0e0;
}

.button-color--normal:active {
    background-color: #bdbdbd;
}

.button-color--primary {
    background-color: #2196f3;
    color: #fff;
}

.button-color--primary:hover,
.button-color--primary:focus {
    background-color: #1e88e5;
}

.button-color--primary:active {
    background-color: #1976D2;
}
</style>
```

# Writing stories

When installed, Storybook creates the `stories/` directory with a few sample stories in it. Let's delete those samples and add our own stories in `stories/Button.stories.js` for the button component.

```js
import Button from '../src/components/Button';

export default {
    title: 'Button',
};

export const normalButton = () => ({
    components: { Button },
    template: '<Button>Normal Button</Button>',
});

export const primaryButton = () => ({
    components: { Button },
    template: '<Button color="primary">Normal Button</Button>',
});
```

The code above uses the new [Component Story Format](https://storybook.js.org/docs/formats/component-story-format/) which has some nice benefits, including the ability to use our stories outside of Storybook - for example, in our automated tests.

We can now run Storybook and visit the provided URL to see the stories: 

```sh
yarn storybook
```

<figure>
![Screenshot of the two stories for the Button component in Storybook](https://thepracticaldev.s3.amazonaws.com/i/c9dws26yw7yp5t9yatmc.gif)
<figcaption>The two stories for the Button component in Storybook</figcaption>
</figure>

With that, we have a working Storybook setup, one that is [suggested in the Storybook docs](https://storybook.js.org/docs/guides/guide-vue/#step-4-write-your-stories). But I don't like the idea of writing stories in string templates, since there's no syntax highlighting and other useful editing controls. An alternative is to write JSX, but that comes with trade-offs, and I don't think the full power of JavaScript is necessary in this case.

What if we could use Vue's [single file components](https://vuejs.org/v2/guide/single-file-components.html) (`.vue` files) to write stories? Turns out we can!

# Writing stories in single file components

Let's move each story to its own file. The `.story` suffix in the filename is not necessary, but serves as a quick indicator that the component is a story.

`stories/ButtonNormal.story.vue`:

```html
<template>
    <Button>Normal Button</Button>
</template>

<script>
import Button from '../src/components/Button.vue';

export default {
    name: 'ButtonNormal',
    components: { Button },
};
</script>
```

`stories/ButtonPrimary.story.vue`:

```html
<template>
    <Button color="primary">Primary Button</Button>
</template>

<script>
import Button from '../src/components/Button.vue';

export default {
    name: 'ButtonPrimary',
    components: { Button },
};
</script>
```

We now update `stories/Button.stories.js` to use the new components:

```js
import ButtonNormal from './ButtonNormal.story.vue';
import ButtonPrimary from './ButtonPrimary.story.vue';

export default {
    title: 'Button',
};

export const normalButton = () => ButtonNormal;

export const primaryButton = () => ButtonPrimary;
```

Now running `yarn storybook` should produce the same stories as before, except this time they're written in single file components.

## What have we gained?

As is usual when there are different approaches to doing the same thing, every approach comes with a trade-off. The main downside to this approach in this case is the extra file and associated boilerplate of the SFC format that's now needed for each story.

But I think it's worth it for what we gain:

- Idiomatic Vue templates with syntax highlighting and full editor support
- Scoped CSS styles for stories when we need it
- A tidier way to organize code for bigger stories

We could stop here, but there's one important improvement we can make: adding the ability to view the source of the story in the Storybook UI.

# Viewing the source of stories in Storybook

There is the official [Storysource addon](https://github.com/storybookjs/storybook/tree/master/addons/storysource) which adds support for viewing the source of stories in Storybook. Unfortunately we can't use it as it won't work with our setup here: it assumes we've written our story inline, but we haven't - they are imported from separate files.

To view the source of our stories, we need to extend the Storybook UI with our own source panel that will work with this setup. To do that, we will:

- Add an `<include-source>` [custom block](https://vue-loader.vuejs.org/guide/custom-blocks.html) to our story component files, and write a custom webpack loader to load the story source
- Write an addon to display the source in Storybook UI

## The `<include-source>` custom SFC block

The first step is reading the story source and attaching it to the story object at build time so it's available to show in the UI at runtime. To do this, we need two things:

- The path to the `.story.vue` file, so we can read it
- A webpack loader to read the source and attach it to the component

Unfortunately, webpack loaders don't have direct access to the path of the file with the current piece of code (or "entry" in webpack terms) that they're processing. What they do have access to, however, is source of said entry. So we can embed the path of the file in the entry, and use that instead.

A good way to do this is using Vue Loader's custom blocks feature, which allows us to define our own blocks next to the default `<template>`, `<script>`, and `<style>` blocks. Vue Loader will parse the block and pass its content to a custom webpack loader, which will also receive the parsed component to annotate.

## Adding the `<include-source>` custom block

At the end of each `.story.vue` file, let's add the `<include-source>` block with the path of the file.

`stories/ButtonNormal.story.vue`:

```diff
+ <include-source>stories/ButtonNormal.story.vue</include-source>
```

`stories/ButtonPrimary.story.vue`:

```diff
+ <include-source>stories/ButtonPrimary.story.vue</include-source>
```

Now let's extend the Storybook webpack config to add a loader that handles the custom block. Create a file at `.storybook/webpack.config.js` with the following content:

```js
const path = require('path');

module.exports = ({ config }) => {
    // Add a custom loader to load and attach the source of the file
    // specified in a <include-source> custom block of a Vue file
    config.module.rules.push({
        // The block type: <include-source>
        resourceQuery: /blockType=include-source/,
        // The custom loader: source-loader.js file in the current directory
        loader: path.resolve(__dirname, 'source-loader.js'),
        // Pass the repo's root path in the loader options to resolve the
        // relative source file paths
        options: {
            rootPath: path.resolve(__dirname, '..'),
        },
    });

    return config;
};
```

Then create a file at `.storybook/source-loader.js` with the custom loader:

```js
const fs = require('fs');
const path = require('path');

module.exports = function(source, sourceMap) {
    // `source` (the string in the custom <include-source> block) contains the file path
    const filePath = path.join(this.query.rootPath, source.trim());

    // Read the referenced file and remove the <include-source> block, so it doesn't
    // show up in the source code that will be shown in the UI
    const fileContent = fs
        .readFileSync(filePath, 'utf8')
        .replace(/<include-source>.*<\/include-source>\n/, '');

    // Generate a function that'll receive the Vue component and attach the source
    this.callback(
        null,
        `export default function (Component) {
            Component.options.__source = ${JSON.stringify(fileContent)};
        }`,
        sourceMap
    );
};
```

## Adding the source panel in Storybook

With the source of each story attached to the corresponding component during build time, we can write a Storybook addon that adds a new panel to display the source code.

Create a file at `.storybook/source-addon.js` with the following content:

```js
import React from 'react';
import { addons, types } from '@storybook/addons';
import { useParameter } from '@storybook/api';
import { AddonPanel } from '@storybook/components';
import { SyntaxHighlighter } from '@storybook/components';

const ADDON_ID = 'vueStorySource';
const PARAM_KEY = 'source';
const PANEL_ID = `${ADDON_ID}/panel`;

// The SourcePanel component (React)
const SourcePanel = ({ active }) => {
    const source = useParameter(PARAM_KEY, null);
    return active && source
        ? React.createElement(
                SyntaxHighlighter,
                {
                    language: 'html',
                    showLineNumbers: false,
                    copyable: true,
                    padded: true,
                    format: false,
                },
                source
            )
        : null;
};

// Register the addon
addons.register(ADDON_ID, () => {
    const render = ({ active, key }) =>
        React.createElement(
            AddonPanel,
            { active, key },
            React.createElement(SourcePanel, { active })
        );

    addons.add(PANEL_ID, {
        type: types.PANEL,
        title: 'Source',
        render,
        paramKey: PARAM_KEY,
    });
});
```

The code above defines a `SourcePanel` React component that uses the [`useParameter`](https://storybook.js.org/docs/addons/api/#useparameter) Storybook hook to get the story's source and render it using the [`SyntaxHighlighter`](https://github.com/storybookjs/storybook/tree/master/lib/components/src/syntaxhighlighter) component included with Storybook. The `source` parameter will be read from the story's [`parameters`](https://storybook.js.org/docs/basics/writing-stories/#parameters) object, which is set as shown below.

## The `story()` helper function

To add the `source` parameter, we need to get the `.story.vue` component source and attach it to the story object when it is defined. Since we'll be doing this for every story, let's write a `story` helper function that wraps that logic.

Create a new file at `stories/story.js` with the following content:

```js
export function story(StoryComponent, options = {}) {
    // Get the `withSource` option, default to true. Making this an option
    // allows us to opt-out of displaying the source of a story.
    const { withSource } = Object.assign({ withSource: true }, options);

    // The story export that Storybook will use
    const storyExport = () => StoryComponent;

    // Attach the source as a story paramter
    if (withSource) {
        storyExport.story = {
            parameters: {
                // `.__source` is from our custom <include-source> SFC block
                // and webpack loader
                source: StoryComponent.__source,
            },
        };
    }

    return storyExport;
}
```

Now we update the definition of each story in `stories/Button.stories.js` to use this helper which will attach the source as a story parameter:

```js
import { story } from './story';
import ButtonNormal from './ButtonNormal.story.vue';
import ButtonPrimary from './ButtonPrimary.story.vue';

export default {
    title: 'Button',
};

export const normalButton = story(ButtonNormal);

export const primaryButton = story(ButtonPrimary);
```

If we don't want the source on a certain story, we can disable the source attachment by passing `{ withSource: false }` as the second parameter to the `story()` function:

```js
export const storyWithDisabledSource = story(MyStory, { withSource: false });
```

## Registering the source addon with Storybook

The final thing to do to see our new panel in Storybook is to register the addon. Update `.storybook/addons.js` to import and register the new addon:

```js
import '@storybook/addon-actions/register';
import '@storybook/addon-links/register';

import './source-addon';
```

Now running `yarn storybook` should add a new panel ***Source*** which will show the source of the selected story.

<figure>
![Screenshot of the two stories of the Button component with their source in Storybook](https://thepracticaldev.s3.amazonaws.com/i/n1dmn013ju29s6ij1epl.gif)
<figcaption>The two stories of the Button component with their source in Storybook</figcaption>
</figure>

# Conclusion

In this post I've shown a decent setup for writing stories in single file components with the ability to view the source of the stories in Storybook. I'm happy to answer questions about this in the comments below.

The complete source code for this post is available on GitHub for reference at  [https://github.com/JosephusPaye/vue-storybook-tutorial](https://github.com/JosephusPaye/vue-storybook-tutorial).

The idea for this post came from my work on [Keen UI](https://github.com/JosephusPaye/Keen-UI/), a lightweight Material-inspired UI component library for Vue. Check out the [storybook branch](https://github.com/JosephusPaye/Keen-UI/tree/storybook) for a real-world example of this setup in use.

# Addendum

This post is a part of my [#CreateWeekly project](https://dev.to/josephuspaye/createweekly-create-something-new-publicly-every-week-in-2020-1nh9), an attempt to create something new publicly every week in 2020.