An AIS client recently started developing a design system of components commonly used throughout multiple Angular 9 applications. To that end, we decided to take a component-driven approach using Storybook JS, version 6.1. We developed individual base components that would be used in more complex components, and then we would use a combination of those complex components and base components in web pages.

We quickly discovered that though Storybook supports Angular and other frameworks, it is primarily documented for React development. We also found some other techniques that are useful for development and documentation.

Here’s an overview of what we’ll cover in this piece:

  • Development
    • Rebuilding and Watch Mode
    • Router
    • Testing Components that Rely on the Output of Other Components
    • Mocking Services
  • Documentation
    • JSDoc Tags
    • Quotation Marks in JSDoc
    • MDX or TS Documentation?
    • documentation.json

Development

Rebuilding and Watch Mode

When developing with Storybook locally with ‘npm run storybook,’ Storybook has a watch mode that monitors an application’s source code and automatically rebuilds the application, similar to Angular’s watch mode when using ‘ng serve.’

There are a few minor caveats to Storybook’s watch mode, however. If you make changes to any of Storybook’s configuration files in the ‘.storybook’ directory, these files are not rebuilt by the Storybook process. If changes are made to the ‘.storybook’ directory, halt the ‘npm run storybook’ process, and restart it. Similarly, if you add new assets such as images or fonts to your application’s assets folder, Storybook will not package these new assets until you restart the process.

Router

If developing a project that uses the Angular router, and attempt to import the AppModule into the ModuleMetadata of one of your stories, then you will see the following error:
Unhandled Promise rejection: No base HREF set. Please provide a value for the APP_BASE_HREF token or add a base element to the document. ; Zone: ; Task: Promise.then ; Value: Error: No base HREF set. Please provide a value for the APP_BASE_HREF token or add a base element to the document.

The easiest way to avoid this is not to import any module that defines any routes inside it or any of its imported modules. Instead, directly import the components that your application relies on in the story, or specify another module to import that contains only your components and not your application:

export default {
  title: 'My Story Name',
  component: MyStoryComponent,
  argTypes: {

  },
  decorators: [
    moduleMetadata({
      declarations: [
        MyStoryComponent,
        MyDependentComponent
      ],
    }),
  ]
};

If you have components that include the Angular router tests, look at the “Mocking Services” section of this post.

Testing Components that Rely on the Output of Other Components

One component we were developing in our application was a Password Strength Meter, which changes its styles and colors based on the complexity of the password provided to the meter. It took this password as an input property on the component. At first, we allowed the Storybook website user to modify the input property directly in the component’s property list in the Storybook Controls addon. However, we decided this was an insufficient test.

We discovered that it was effortless to define a new Angular component in the .stories.ts file for the Password Strength Meter. You specify this the same way you’d describe any other Angular component, using the @Component decorator, and implementing ngOnInit. Since it is not exported in an Angular module, this component will not be included in the deployment. We defined the component’s Template HTML inline, including both a text input field and our Password Strength Meter. We managed any of the ngModel events in the definition of the component.

Within the same .stories.ts file, you can define separate templates for individual stories. In that way, we can have both a “No text field” story that tests the Password Strength Meter without a text field and an “Integration” story that tests the password strength meter integrated with a text field:

const NoTextFieldTemplate = (args: PasswordStrengthComponent) => ({
  props: args,
  component: PasswordStrengthComponent,
});
export const NoTextField = NoTextFieldTemplate.bind({});
NoTextFieldTemplate.args = {
  ...actionsData
};
const IntegrationTemplate = (args: PasswordStrengthTestComponent) => ({
  props: args,
  component: PasswordStrengthTestComponent,
});

export const IntegrationWithTextField = IntegrationTemplate.bind({});
IntegrationWithTextField.args = {
  ...actionsData
};

Mocking Service

In our application development, we ran into situations where we would need to develop components that used dependency injection to obtain both built-in Angular services like the router and our services.
Since we define an inline Angular module in our story definition using ‘moduleMetadata’, it’s simple enough to add a “providers” line and use the “useValue” property.
For example, let’s say we have a service called SessionService that stores the user’s local session data, including their username. That service has a method named “getUsername()” that a component relies on. Also, this component utilizes the router. Then, inside our “.stories.ts” file, we can define our inline module:

export default {
  title: 'Header',
  component: BannerComponent,
  argTypes: {

  },
  decorators: [
    moduleMetadata({
      declarations: [
        BannerComponent
      ],
      imports: [
        RouterModule.forRoot([])
      ]
      providers: [
        {
          provide: SessionService, useValue: {
           getUsername: () => {
             console.log(`SessionService.getUsername called`);
             return `user1`;
           },
          }
        },
        {provide: APP_BASE_HREF, useValue: '/'}
      ]
    }),

  ]
};

In this example, we can see a mock of the SessionService that provides a fixed value to getUsername and the APP_BASE_HREF value that the Angular router relies on.

Documentation

One of the most significant benefits of Storybook is the amount of documentation that can be generated. Storybook uses Compodoc to generate its documentation, but you do have some options for overriding the default documentation generated. Storybook also offers many additional properties, as explained in their documentation.

Compodoc uses JSDoc to allow developers to design documentation for components easily. However, there are some limitations, as well as some other options for documentation.

JSDoc Tags

Compodoc only supports a small number of JSDoc tags, as documented on their website: @returns, @ignore, @param, @link, and @example. By far, the most useful of these in Storybook is @ignore. The @ignore tag will remove the property from the Storybook Controls panel, which helps hide properties and functions that external components should not use. Storybook infers many of these other properties, and we can define code examples in either the .stories.ts file or the .stories.mdx file.

Quotation Marks in JSDoc

One of the first things we found when writing JSDoc for Storybook is that either Compodoc or Storybook does not escape quotation marks properly for displaying in web browsers. We had to use the HTML escape for apostrophes and quotes, “’” and “”” inside our JSDoc documentation to get around this issue.

MDX or TS Documentation?

MDX is a file format similar to Markdown provided as an option for Storybook documentation. We found that MDX is slightly harder to use for Angular developers, as it requires learning a new syntax based on React development. Despite this drawback, MDX gives the ability to provide complex Markdown formatting to the pages. When developing in Storybook, consider using MDX if you need an involved documentation page that does not fit the default Storybook documentation formatting style.

You can mix and match MDX with Typescript and define your stories in a TS file and import them in an MDX file if you need complex story definitions alongside complex markdown.
Often, TS documentation will be enough; we can use the various properties Storybook provides alongside JSDoc. However, for those times that we need a bit more customization, MDX files can provide what we need.

documentation.json

When Compodoc builds, it creates a large file in the root directory of the application called documentation.json. This file is created when Compodoc is run. This file does not need to be committed to git and can safely be included in a .gitignore file.

Conclusion

Storybook is a powerful tool used to develop and document components in Angular. Though its documentation is primarily written for React, its capabilities are equally strong with Angular and will help design document reusable components for a large application.