Working with Storybook and Drupal (Part 2)

storybook post

Last fall, we talked about our efforts to integrate Storybook with Drupal 8 to make use of component libraries in our projects. In that post, we talked about our process of finding the right tool, some of the challenges we faced integrating into Drupal and gave a “mid-mortem” of how we were feeling about it.

Now it’s time for the post-mortem and to share some of the technical tips and tricks we found helpful in making the setup work.

Not long after sharing our first post, Albert Skibinski wrote a great piece on integrating Storybook in with Drupal. It was the guide we wish we’d had a few months earlier and essentially what I had originally intended to do with this post. 

Rather than re-writing an already thorough and helpful how-to guide, I’ll share how we got around some of the “gotchas” we encountered along the way.

Basic structure

For the sake of clarity in the rest of this post, the basic structure of our D8 theme with Storybook looks like this:

Theme folder

  |– build

  |– storybook

    |– atoms

    |– (other atomic design folders)

    |– organisms

  |– templates

Within each atoms/molecules/organisms folder we included a folder for each component that includes a twig template, scss file, stories.js file, data.js file, and custom javascript in another .js file if needed.

Leveraging Drupal attributes in Storybook

In our original Storybook setup, we didn’t have a setup to leverage tools like Drupal attributes in Storybook. This is where Skibinski’s aforementioned blog post really changed my life in that I learned about the existence of the drupal-attribute and twig-drupal-filters npm packages. In addition to the absolutely essential step of using Twig-loader in the Webpack config, these are what makes it possible to write straight Drupal templates in Storybook.

Now through a little coding magic, I can create a block.twig template in Storybook:

{%
  set classes = [
    'block',
    'block-' ~ configuration.provider|clean_class,
    'block-' ~ plugin_id|clean_class,
  ]
%}

<div{{attributes.addClass(classes)}}>
  {{ title_prefix }}
  {% if label %}
    <h2{{title_attributes}}>{{ label }}</h2>
  {% endif %}
  {{ title_suffix }}
  {% block content %}
    <div {{ bem('some-class', ['modifier'], '', []) }}>
      {{ content }}
    </div>
  {% endblock %}
</div>

Our block.data.js file provided all the variables used within that twig template:

import drupalAttribute from 'drupal-attribute';

export const blockData = {
  attributes: new drupalAttribute(),
  title_attributes: new drupalAttribute(),
  plugin_id: 'Some plugin',
  title_prefix: '',
  title_suffix: '',
  label: 'I am a block!',
  content: 'I am rendering this block in Storybook.',
  configuration: {
    provider: 'Some module',
  },
};

export default blockData;

The stories file brings it all together in Storybook.

import { storiesOf } from '@storybook/html';

import Block from './block.twig';
import { blockData } from './block.data';

storiesOf('Organisms/Blocks', module).add('Block', () => Block(blockData));

And finally, our block.html.twig template override file can be as simple as

{% include "@storybook/block/block.twig" with {...} %}

As mentioned previously, Skibinski goes into more detail in his guide, so I’m leaving out the finer details here. Without a doubt though, this is the key to make moving between Storybook and Drupal as painless as possible.

Solving the JavaScript problem

As mentioned in part 1, we had trouble getting JavaScript to play nicely with both Drupal and Storybook. At the beginning of our process, we testing the connection with console.log() statements, but when it came to actually adding interaction, using JS in Storybook was a no-go.

Skibinski suggests a method using a Babel plugin to wrap existing JavaScript. I had mixed results testing with this and ultimately didn’t like that I would have a separate directory for Drupal to reference. Ideally if Drupal is referencing a Twig file from one directory, I’d like it to be able to reference the JS file in that same directory. Plus, I’d like to be saved the trouble of having to run a separate command to re-process the JS files specifically for Drupal.

Ultimately I came up with a solution that allows developers to write ES6 classes that both Storybook and Drupal can utilize. Rikki Bochow shared a guide on removing jQuery from Drupal themes that I referenced while working on this particular setup. Using jQuery vs. ES6 is a different conversation, but I did use ES6 here.

First things first, let’s write some JavaScript.

Below is a very basic constructor that will change the style of a block when based on mouseover/mouseout and will log this (the block element we are affecting) to the console. 

export class BlockScript {
  // The block parameter is passed by Drupal in below code.
  // It's passed by Storybook within the useEffect block.
  constructor(block) {
    // We need to add the block parameter being passed onto "this" within our class.
    Object.defineProperty(this, 'block', { value: block || null });
    this.bindEvents();
  }

  bindEvents() {
    this.block.addEventListener('mouseover', this.onMouseover);
    this.block.addEventListener('mouseout', this.onMouseout);
  }

  onMouseover() {
    console.log(this);
    this.setAttribute('style', 'color:red; border: 1px solid blue;');
  }

  onMouseout() {
    console.log(this);
    this.setAttribute('style', 'color:black; border: none;');
  }
}

To wrap this class function in Drupal behaviors, I could simply add the behaviors attach a function to the bottom of the file, passing the block as needed and call the constructor function from my block.stories.js file.

(({ behaviors }, { theme_name }) => {
    behaviors.button = {
      attach(context) {
        context.querySelectorAll('.block').forEach(block => {
          new BlockScript(block);
        });
      },
    };
  })(Drupal, drupalSettings);

However, if I try to preview that same code in Storybook, I get: Drupal is not defined

There is the option of the aforementioned Babel CLI method, or I could explore passing Drupal in some way to Storybook. As developers I sometimes feel we skip to the most complicated solutions and overlook simple solutions:

I can just tell the code to stop executing before it hits the Drupal behaviors block.

// Only run Drupal code if Drupal is defined.
if (typeof Drupal !== 'undefined') {
  (({ behaviors }, { theme_name }) => {
    behaviors.button = {
      attach(context) {
        context.querySelectorAll('.block').forEach(block => {
          new BlockScript(block);
        });
      },
    };
  })(Drupal, drupalSettings);
}

On the Storybook side, I’ll make use of useEffect() to bring in my class function from the js file.

storiesOf('Organisms/Blocks', module).add('Block', () => {
  useEffect(() => {
    document.querySelectorAll('.block').forEach(block => {
      // eslint-disable-next-line
      new BlockScript(block);
    });
  }, []);
  return Block(blockData);
});

In short, Drupal is using behaviors to attach the function, and Storybook is using useEffect()`. In the end, I get the same JavaScript result in Storybook:

recording of working code in Storybook

And in Drupal:

recording of working code in Storybook

 

Using custom Twig filters & functions

On a recent project, we made us of Emulsify’s BEM Twig Function. This was really helpful in our Twig templates, but Storybook lives separately from Drupal and has no knowledge of what bem() is. Fortunately Twig.js includes .extendFunction() and .extendFilter().

In our case, I adapted BEM’s function into JavaScript code, making some adaptations.

import drupalAttribute from 'drupal-attribute';

const bemExtension = (baseClass, modifiers, blockname, extra) => {
  let classes = [];

  if (modifiers && !Array.isArray(modifiers)) {
    modifiers = [modifiers];
  }

  if (extra && !Array.isArray(extra)) {
    extra = [extra];
  }

  // If using a blockname to override default class.
  if (blockname) {
    // Set blockname class.
    classes = [...classes, `${blockname}__${baseClass}`];
    // Set blockname--modifier classes for each modifier.
    if (modifiers && modifiers.length > 0) {
      for (let i = 0; i < modifiers.length; i += 1) {
        const modifier = modifiers[i];
        if (modifier !== '') {
          classes = [...classes, `${blockname}__${baseClass}--${modifier}`];
        }
      }
    }
    // If not overriding base class.
  } else {
    // Set base class.
    classes = [...classes, baseClass];
    // Set base--modifier class for each modifier.
    if (modifiers && modifiers.length > 0) {
      for (let i = 0; i < modifiers.length; i += 1) {
        const modifier = modifiers[i];
        if (modifier !== '') {
          classes = [...classes, `${baseClass}--${modifier}`];
        }
      }
    }
  }

  // If extra non-BEM classes are added.
  if (extra && extra.length > 0) {
    for (let i = 0; i < extra.length; i += 1) {
      const extraClass = extra[i];
      if (extraClass !== '') {
        classes = [...classes, extraClass];
      }
    }
  }

  const attributes = new drupalAttribute();

  return attributes.addClass(classes.join(' '));
};

export default bemExtension;

In our Storybook config.js file, we just need to import our newly written function and use Twig.extendFunction() to use it throughout our Storybook project.

twigDrupal(Twig);
Twig.extendFunction('bem', bemExtension);

 

A post-mortem: why we aren’t using Storybook

As we’ve continued to start new projects and consider the toolsets we’d like to use, we have found ourselves opting out of using Storybook. While we have continued using a component-based development approach to development, our need for a design system is limited. As storybook says in their documentation:

For small projects, the cost of maintenance, integration, and tooling far outweighs any productivity benefits you might see. The economy of scale in a design system works in your favor when sharing UI components across many projects.

We’re a relatively small team at Savas, with 3-4 front-end developers at most working on a project at any given time. Our designs and components are different with each client we work with. Since we aren’t sharing components across multiple projects and our clients don’t require a design system, using Storybook just isn’t the most efficient approach

Why the work was worth it

I spent a lot of my allotted training time working on our Storybook/D8 base theme and solving a lot of these issues. It is, of course, a little frustrating to have done the work and not be able to “use” it, but I personally found the time I spent working on this setup invaluable.

I started at Savas last summer as a junior developer and dropped the junior from my title at the beginning of this year. Part of what facilitated my growth in learning front-end technologies and learning the theming layer of Drupal was working on this setup! I learn best by doing - trying something and failing over and over until I finally figure it out. This was a great ‘playground’ so to speak for that to happen.

Attempting to get this working meant that I dove further into Webpack than I’d had an opportunity to previously. The work I did in Webpack for this purpose actually became the basis for our Webpack configuration on building this very site.

If nothing else, I hope some of the tips here can help you in your Storybook setup! If you have any questions, feel free to hit us up on Twitter at @SavasLabs!