Drupal 11: Using Storybook To Preview Single Directory Components

Single Directory Components (SDC) consist of a directory of files that go together to create a small component on a Drupal website. The component contains all the templates, styles, script, and images needed to display some content in a consistent way.

Different SDC can be nested together, which means that a site can be built up from different components working together to generate the content.

The power of SDC comes from their ability to be self contained. If you have the need to build a complex component that displays data in a widget then building it as a SDC means that you can ensure that it looks and functions in the same way every time you include it.

Storybook is a JavaScript application that provides a front end workshop for component development. This means that we can develop and preview our components before they are used on the site. By using a module we can build components in our Drupal theme and then preview them in Storybook before ever needing to inject them into the Drupal templates.

In this article we will look at creating an SDC in Drupal and then using Storybook to create and preview that component.

First, let's make an SDC that we can use as an example with this application.

Creating A Single Directory Component

To preview an SDC in Storybook we first need to create one, this will be used as an example thoughout the rest of the article. It is assumed that you have a custom theme that you can build an SDC in.

We won't do a deep dive into SDC here since it can be a large subject, so we'll just create the needed elements. If you want more information then the official Drupal documentation on SDC is actually very good. There is also a Drupal 10 Theme Development book that has a comprehensive guide on building and using SDC in Drupal.

For this example I will create a simple author component that will display the name, bio, and avatar of the author of an article. Here are the needed files for this component to function.

The author.component.yml file defines an author URL as a property and a a few slots to pass in the name, bio, and avatar information.

name: Author
description: "Display author information"

props:
  type: object
  properties:
    author_url:
      type: string
      title: Author URL
      examples:
        - /author/philipnorton42

slots:
  name:
    title: "Name"
  bio:
    title: "Bio"
  avatar:
    title: "Avatar"

There is quite a bit written about the difference between props and slots, but for the purposes of this example props is just the URL of the author and slots is everything else. We need to pass Avatar and Bio as a slot as these items will contain HTML or even presented as a renderable item.

In the main author.twig file we create some simple markup and add in the attributes defined in the author.component.yml file. The blocks are important here, but they will only become useful when we setup the stories for Storybook which we will cover later in the article.

<aside class="author">
  <div>
   {% block avatar %}
     {{ avatar }}
   {% endblock %}
  </div>
  <div class="author_bio">
    <p>
      <a href="{{ author_url }}" rel="bookmark">
        <span>{{ name }}</span>
      </a>
    </p>
    {% block bio %}
      {{ bio }}
    {% endblock %}
  </div>
</aside>

The CSS file of the author component is stored on the author.css and is relatively straightforward. It just creates an area with a box shadow and a flex container with a left and right section.

aside.author {
  clear: both;
  box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 10%), 0 4px 6px -2px rgba(0, 0, 0, 10%);
  padding: 1rem;
  margin-bottom: 2rem;
  margin-top: 2rem;
  display: flex;
  aside.author:first-child {
    width: 20%;
    text-align: center;
    img {
      max-width: 100%;
      height: auto;
    }
  }
  aside.author:last-child {
    width: 80%;
  }
  div.author_bio {
    margin-left: 1rem;
    p {
      margin-top: 0;
    }
  }
}

These three files all live in a directory called author, which is in a theme called my_theme.

The Storybook Module

The connection between Drupal and Storybook is provided by the Drupal Storybook module. This modules provides the needed Drush commands that we can use to create the files needed for Storybook, and also has a number of endpoints that allow Storybook to pick up and render the components.

The first step to get them working together is to require the Storybook module.

composer require drupal/storybook

Then install it like any other module.

drush pm:install storybook

Now, we need to create a <component name>.stories.twig file for each component you want to display in storybook. Stories are defined with a {% stories %} twig tag, and each story is defined within a {% story %} twig tag inside this.

A typical stories file would have the following basic structure.

{% stories componentName with { title: 'Components/ComponentName' } %}

  {% story default with {
    name: '1. Default',
    args: {
      some_property: 'A value'
    }
  } %}

  {% embed 'my_theme:componentName' %}
  {% endembed %}

  {% endstory %}
  
{% endstories %}

The title of the stories section is used to display the story in the menu bar on the left hand side of Storybook. Each name of the story is used to name the story in the same menu bar, as the first story is always shown first it is commonly called Default.

Any arguments that you set in the args section of the story are passed through to the Storybook application and this allows users to tweak those arguments within the Storybook interface.

You can optionally pass variables to the embed tag using the with attribute. Any variables that we create using the args attribute are automatically available to the templates inside the embed statement, but this is a useful way of adding additional (static) overrides to the template.

  {% embed 'my_theme:componentName' with {another_property: 'Another value'} %}
  {% endembed %}

If your component has no HTML elements then you can simplify the story slightly by using the include twig tag. This passes the arguments to the template, but Twig will escape any strings and any markup will be lost. An ideal situation if there is no HTML being passed to the Twig template.

  {% story default with {
    name: '1. Default',
    args: {
      some_property: 'A value'
    }
  } %}

  {{ include('my_theme:componentName', {  
    some_property
  }) }}

  {% endstory %}

The good thing about this structure is that we can add additional examples to the *.stories.twig file if we wanted to demonstrate the component being used in different ways. This is done by adding an additional {% story %} tag for every example we want to add.

In the following example, we are adding a second example to the story to show the component with a much longer title and as part of a list element (just to show the component within different markup as a silly example).

{% stories componentName with { title: 'Components/ComponentName' } %}

  {% story default with {
    name: '1. Default',
    args: {
      some_property: 'A value'
    }
  } %}

  {% embed 'my_theme:componentName' %}
  {% endstory %}

  {% endstory %}
  
  {% story wrappedComponent with {
    name: '2. Wrapped Component',
    args: {
      some_property: 'A slightly longer title that might cause the design to break if our styles are not correct.'
    }
  } %}

  <ul>
  <li>
  {% embed 'my_theme:componentName' %}
  {% endembed %}
  </li>
  </ul>

  {% endstory %}
  
{% endstories %}

This allows us to add different examples of the component being used with different properties, or different surrounding (or injected) HTML markup. If we had a component that accepted another component as the content then we could provide stories that showed how the component reacted with different injected markup.

For our author component we created a file called author.stories.twig, and because we have HTML to pass to the template we need to use the embed Twig tag. This is where the use of the Twig block tags comes into play. In order to transmit the values in the args section of the story to the template we need to override the blocks and inject the args in a raw form to the template. This allows us to use HTML in the Storybook arguments without having to add raw output to the template itself, which can be insecure. It also allows the args to be used to in the Storybook interface.

The full stories file for the author component looks like this.

{% stories author with { title: 'Components/Author' } %}

  {% story default with {
    name: '1. Default',
    args: {
      author_url: '/author/philipnorton42',
      name: 'Phil Norton',
      bio: '<p>Phil is the founder and administrator of <a href="https://www.hashbangcode.com">#! code</a> and is an IT professional working in the North West of the UK.</p>',
      avatar: '<img loading="lazy" src="https://picsum.photos/id/237/480/480" width="480" height="480" alt="Test image" class="image-style-medium">'
    }
  } %}

    {% embed 'my_theme:author' %}
      {% block avatar %}
        {{ avatar|raw }}
      {% endblock %}
      {% block bio %}
        {{ bio|raw }}
      {% endblock %}
    {% endembed %}
  {% endstory %}

{% endstories %}

I'm using https://picsum.photos/ to generate a test image here, rather than use any real content, but the size of the image will be the same as generated in Drupal.

Once you have the stories in place you can then run a Drush command to generate all of the stories. Storybook can't make use of the <component name>.stories.twig files itself and needs a <component name>.stories.json file to link it to Drupal. This file just contains information about the arguments present, but the main <component name>.stories.twig file is still used to render templates.

We can generate the story for a single component using the storybook:generate-stories command, giving it a path to the component (relative to the Drupal web root).

drush storybook:generate-stories themes/custom/my_theme/components/author/author.stories.twig

Alternatively, we can generate all of the stories at once.

drush storybook:generate-all-stories

This command creates a <component name>.stories.json file for every <component name>.stories.twig file in your theme. I have found that even with 40+ components this command only takes a few seconds to run, and will only update stories files if the twig file has been updated since it was last generated.

NOTE: Don't commit these *.stories.json files to your repo as they are generated with the URL of the current site in them. This means that Storybook will reference files absolutely through your Drupal install. In fact a good idea is to add a .gitignore file to your theme to prevent these files from being comitted.

# Ignore all stories.
*.stories.json

If you do want to use Storybook as a hosted application then you will need to build the Storybook json files when you deploy.

You can watch for changes in your Twig files and run this command automatically using the following.

watch --color drush storybook:generate-all-stories

If you are actively developing with SDC and Storybook then you will need to turn on the Twig development mode, which can be done in Drush using the following commands.

drush state:set twig_debug 1
drush state:set twig_cache_disable 1
drush state:set disable_rendered_output_cache_bins 1

Doing this makes theme development much easier as it will prevent Twig templates from being cached and also add HTML comments to your theme to show what templates are being used.

Finally, the Storybook module comes with a permission called render storybook stories that is required to render the stories. The following command will activate this permission for anonymous users. 

drush role:perm:add anonymous 'render storybook stories'

Make sure you don't have this permission turned on for anonymous users on production. If, indeed, you are activating the module in the production environment.

With the module setup and the components configures we can now look at installing Storybook.

Installing Storybook

Storybook is a standalone application that will send requests to Drupal to render the components that you have configured with stories files. Whilst it is possible to install Storybook in the root of your Drupal project I strongly suggest that you give Storybook its own directory to exist in. This prevents Storybook becoming a hard dependency of your project and interfering with other packages like eslint that you might actually want installed in the root of your project for coding standards purposes.

The module documentation for the Drupal Storybook module talks about using yarn to install Storybook, but I'll be looking at using npm to install and run Storybook in this article.

Create a directory called something like storybook or tests; it just needs to be somewhere for Storybook to live. Then, install Storybook using the following commands.

npm init -y
npx storybook init --type server

This will install Storybook and start it on your local environment; it will also add some configuration items to your package.json file.

You should end up with a package.json file that looks like the following.

{
  "name": "tests",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "storybook": "storybook dev -p 6006",
    "build-storybook": "storybook build"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@storybook/addon-docs": "^9.1.8",
    "@storybook/addon-webpack5-compiler-swc": "^4.0.1",
    "@storybook/server-webpack5": "^9.1.8",
    "storybook": "^9.1.8"
  }
}

Storybook comes packaged with a number of default test stories that can get you started if you want to use Storybook as a theme development service. We aren't interested in this, so we need to tell Storybook where our components live.

Open the file called main.js  in the newly created .storybook directory and make sure that the "stories" configuration item looks like this.

  "stories": [
    "../../web/themes/custom/my_theme/components/**/*.stories.@(json|yaml|yml)"
  ],

This tells Storybook to look for stories in our theme, which we created using the Storybook Drupal module.

You can also delete the directory called "stories" inside your tests directory since that contains some example Storybook components to get you started. We are now ignoring them, but you can delete them to prevent them being confused for site related design things in the future.

CORS mitigation

Before you can use Storybook you need to allow Storybook to communicate with your Drupal site. If you don't do this step then you'll see CORS errors in the console of your Storybook site, which will prevent it from working correctly. Your Storybook site will just show a blank screen for the component or it might even show an error message from Storybook.

To bypass the CORS rules you need to add the following parameters to your development.services.yml file in your Drupal site.

parameters:
  storybook.development: true
  cors.config:
    enabled: true
    allowedHeaders: [ '*' ]
    allowedMethods: [ '*' ]
    allowedOrigins: [ '*' ]
    exposedHeaders: false
    maxAge: false
    supportsCredentials: true

The services file must then be referenced in your settings.php file.

$settings['container_yamls'][] = DRUPAL_ROOT . '/sites/development.services.yml';

It's not recommended to run Storybook in your production environment, so this setting should only be used for your local and development/staging sites.

Running Storybook

Finally, we can run Storybook and look at the results of our work.

There are a couple of ways to do this, but let's first focus on running the application locally. Assuming you have Node installed you can change directory to the tests directory (or wherever you installed Storybook) and run the following command.

npm run storybook

This will run the Storybook application, which will automatically open a browser window.

You should be presented by a preview of the story that we configured a while ago.

A running Storybook application window (in a browser). On the left is the Author component we created and on the right is the rendered comonent.

The arguments we stipulated in the <component name>.story.twig file are presented as options in the Storybook interface below the preview window. This allows us to change the values in the component to see how different parameters and options change how the component reacts. By doing this we can test the component with different content or settings without having to refresh the page.

This works because Storybook creates an iframe that points to the Storybook module's endpoint inside Drupal. Doing this allows Drupal to return the markup for the component along with any CSS and JavaScript that is required for the component to work.

In addition, as the component is rendered using the theme that it is contained in you also get all of the theme files associated with that component. What this means is that any global styles set by the theme will be applied to the component and so you can assume that your component will look exactly like this when rendered fully within your theme.

If you encounter a blank screen when attempting to view the story in Storybook then check your Drupal logs for more information. Sometimes the interaction with components through Storybook can cause errors that aren't shown in Storybook, but Drupal will log the error.

Running Storybook In DDEV

A popular way of running Storybook is to use DDEV as the wrapper. This makes sense since you won't need to install Node on your local environment if you use DDEV.

In order to get Storybook to run in DDEV, however,  you need to change a couple of things.

First, edit your .ddev/config file and add the following lines of config. This tells DDEV to open ports 6006 and 6007 for the use of Storybook, and that the node.js daemon should watch the package.json file; which keeps the docker container alive as long as we need it.

web_extra_exposed_ports:
  - name: storybook
    container_port: 6006
    http_port: 6007
    https_port: 6006
web_extra_daemons:
  - name: node.js
    command: "tail -F package.json > /dev/null"
    directory: /var/www/html/tests

As we want to run Storybook as a service without opening it we need to also change the package.json file in the .storybook directory to update the way in which Storybook is started. Open the file and add the directive --no-open to the end of the storybook command.

  "scripts": {
    "storybook": "storybook dev -p 6006 --no-open",
    "build-storybook": "storybook build"
  },

You can now run the following command to start the Storybook application inside DDEV.

ddev exec "cd tests && npm run storybook"

Which you can then view via the address https://<your ddev site>.ddev.site:6006.

There can be issues around Storybook communicating correctly with Drupal if run like this. With the biggest issue being mixed content due to http and https being used at the same time. If you are having problems then try running the storybook:generate-all-stories command with a --uri flag to force the correct domain and protocol.

ddev drush storybook:generate-all-stories --uri=https://drupaldev.ddev.site/

The preview.js file in Storybook can also be altered to point to your site using the correct domain and protocol.

/** @type { import('@storybook/server-webpack5').Preview } */
const preview = {
  parameters: {
    server: {
      url: `https://<your ddev site>.ddev.site/storybook/stories/render`,
    },
    controls: {
      matchers: {
       color: /(background|color)$/i,
       date: /Date$/i,
      },
    },
  },
};

export default preview;

After making these changes you should be able to get everything working correctly.

Building And Hosting Storybook

Instead of running Storybook as an application we can build the files needed for storybook and serve them as a separate website.

To build the Storybook files run the following command in your tests directory. 

npm run build-storybook

This will create the files needed for Storybook in a directory called storybook-static, but you won't be able to do anything with them without having a web server pointing at the files (since they live outside of the web root).

location ^~ /storybook-static {
  add_header "Access-Control-Allow-Origin" *;
  add_header "Access-Control-Allow-Methods" "GET, POST, OPTIONS";
  disable_symlinks off;
  alias /your/project/directory/tests/storybook-static;
  index index.html;
}

This allows you to deploy and serve Storybook as a stand alone site, alongside your development environment. A very useful technique if you are demonstrating your component to your clients as it means you don't need to bake them into the theme until they are signed off.

Hosting Storybook In DDEV

If you are using DDEV and want to use this method then you can run the build command in the following way.

ddev exec "cd tests && npm run build-storybook"

Next, you will need to update the Nginx config at .ddev/nginx_full/nginx-site.conf. Remove the line towards the top of this file that says #ddev-generated and add the following directive to your Nginx config.

    location ^~ /storybook-static {
      add_header "Access-Control-Allow-Origin" *;
      add_header "Access-Control-Allow-Methods" "GET, POST, OPTIONS";
      disable_symlinks off;
      alias /var/www/html/tests/storybook-static;
      index index.html;
    }

After running ddev restart you should be able to see Storybook in the location https://<your ddev site>.ddev.site/storybook-static.

Conclusion

Now that we have previewed the Author component inside Storybook and checked that it works correctly we can add it to our theme. The author component I created at the start is now being used by this theme in the following way (inside a Author minimal view mode template).

{{ include('hashbangcode_theme:author', {
  author_url: url,
  name: label,
  bio: content.field_author_short_bio,
  avatar: content.field_author_avatar
}) }}

If you are going to build using Single Directory Components then you should be doing this with Storybook as well. The way in which you can preview and manipulate components in Storybook means that you can spot issues before they happen. Having the ability to add other markup to the story also means that you can preview the component as it would be on the page, which is essential for components that contain other components.

You should be building SDC in isolation, but if you do find an odd interaction between two components then you can create a story that mimics this interaction and address any issues. Which gives a nice basis for visual regression tests.

Your development workflow should be to build the skeleton of the component and create a story for that component right away. You can then flesh out the component in Storybook and then add it to your theme when it is ready for use.

If you have a theme that contains components already but aren't using Storybook then you should absolutely look at using this system. Adding stories for the components isn't too much of a task, and can only take 10-15 minutes per component.

I used Storybook on a major project that had quite a few little widgets that needed styling in very particular ways. I built a collection of SDCs using Storybook in order to get the styling correct, and when they were dropped into the page they worked perfectly. It was quite rewarding, after hours of work getting the components to look good, to just add it into the site and see it working perfectly.

I have also used Storybook to preview components to clients. In fact, more than one demo has been just showing the component inside Storybook and making changes to the component before it was added to Drupal. By hosting Storybook next to your development site you also allow clients to do the same with their internal shareholders.

Most of the problems with Storybook comes from getting Storybook and Drupal to play nicely together. CORS errors and mixed content mode are probably the most common thing that causes problems with Storybook and Drupal. I have also found that Storybook will sometimes heavily cache the output, especially in the right menu bar, and so getting updates to your components can sometimes be a bit of a pain.

Storybook is a separate application, and so does need updating and maintaining just like any other dependency. By installing it in its own directory, however, we prevent it from adding its footprint to our Drupal site. The Storybook application is being maintained by the developers, so it's not like this is some old application that will be going away soon. At the time of writing this the last Storybook release is 9.1.8, which was released just 5 days ago. I think the benefits of maintaining this application outweigh the time spent creating stories in your components.

This isn't the only way to preview SDC in Drupal, so join me next time where we will be looking at previewing SDC using the SDC - Component library module.

Add new comment

The content of this field is kept private and will not be shown publicly.