Skip to content

Instantly share code, notes, and snippets.

@tmeasday
Last active November 28, 2019 14:03
Show Gist options
  • Star 5 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save tmeasday/4de20eab47226a870ab1025642ba848c to your computer and use it in GitHub Desktop.
Save tmeasday/4de20eab47226a870ab1025642ba848c to your computer and use it in GitHub Desktop.
Storybook example file format

Simple API: export Component (for docs) and examples ("renderables")

import React from 'react';

import Component from 'somewhere';

export default Component;

export const variant1 = () => <Component variant="1" />;
export const variant2 = () => <Component variant="2" />;
export const variant3 = () => <Component variant="3" />;

To use in storybook, simply add the examples file to your examples glob

config.examplesGlob = `**/*.examples.js`; // <- this is the default

Note this isn't a real thing yet, but we'll figure some way to avoid using require.context soon.

The above file will be added to your storybook as Component:variant1, Component:variant2 and Component:variant3. (You can have more complex names, see below).

Usage in other tools

A key benefit of a non-storybook specific file format is it becomes simple to reuse your examples in other tools, such as unit tests. For example:

Jest:

import React from 'react';
import { mount } from 'enzyme';
import renderer from 'react-test-renderer';

import { variant1 } from './component.examples';

describe('variant1', () => {
  it('example renders correctly', () => {
    // Now you can do low-level unit tests on the example
    expect(mount(variant1()).find('div').className).toMatch(/component/);
    
    // Or you can do snapshot testing (although storyshots will still make this better)
    const component = renderer.create(variant1());
    let tree = component.toJSON();
    expect(tree).toMatchSnapshot()
  });
});

Story and chapter parameters / decorators

To add metadata at the example level, add extra properties to the exported function. To do it to the component, export an object rather than the component class.

import React from 'react';
import Component from 'somewhere';

// Parameters or decorators for the component (i.e. all stories):
export default {
  component: Component,
  parameters: { viewports: [320, 1200] },
  decorators: [...],
});

// Parameters for a single story
export const variant1 = () => <Component variant="1" />;
variant1.parameters = { viewports: [320, 1200] };

We'll also make a super simple library to ensure you don't have to think about it:

import React from 'react';
import Component from 'somewhere';
import example from '@storybook/example';

export default example(Component, {
  parameters: { viewports: [320, 1200] },
})

export const variant1 = example(() => <Component variant="1" />, {
  parameters: { viewports: [320, 1200] },
});

FAQ / Rationale

  1. Why not return an object for examples?

We want examples to be consumed as simply as possible. Consider that developers will often be using them directly in tests etc. It is best if it can always be assume an example is a function returning something renderable.

OTOH, the component export (the default export) is more typically used by tooling (e.g. props tables for documentation) and so can be more flexible. Assigning properties on a component class (or equiv in other frameworks) seems dangerous and ugly.

  1. Why mutate the function object?

You don't have to. This works too:

export variant1 = Object.assign(() => <Component />, { parameters: { viewports: ... } });

Our feeling is the mutation syntax is simpler and easier to understand.

Naming

By default, the story "chapter" (or "kind") will take the title from the component's displayName (or equiv.), and the story's title will be the name of the example's export.

So the examples above will be called Component:variant1, Component:variant2 and Component:variant3.

We'll add a config flag that automatically prefixes the component's title with the pathname on disk.

Suppose you have:

config.prefixComponentTitlesByPathFrom = 'src/'

Then src/components/Button.examples.js will name its examples like components/Button:variant1, etc.

Alternatively, we'll support title and titlePrefix props on the component, and title on the example:

export default {
  component: Component,
  titlePrefix: 'components',
  
  // This would be equivalent to
  // title: 'components/Component',
});

// Parameters for a single story
export const variant1 = () => <Component variant="1" />;
variant1.title = 'Initial variant, I like to use wordy story names';

"props" addons

Addons that are more dynamic like actions and knobs have an opportunity to be refactored to better reflect the new format.

Actions

Actions are enabled by default (you can use the actions: { disabled: true } parameter to disable them, although there is no real benefit). To use:

export variant1 = ({ action }) => <Component onSomething={action('onSomething')} />;

We'll provide simple utilities that can be used for other tools:

import React from 'react';
import { mount } from 'enzyme';
import { jestAction } from '@storybook/addon-actions';

import { variant1 } from './component.examples';

it('calls the callback when you click it', () => {
  const action = jestAction();
  const wrapper = mount(variant1({ action }));
  wrapper.find('p').simulate('click')

  expect(action.onSomething).toHaveBeenCalled();
});

Knobs

Knobs are similar, conceptually to actions:

export variant1 = Object.extend(
  ({ knobs: { name } }) => <Component name={name.get} onSetName={name.set} />, {
  knobs: {
    name: 'Default Name',
  }
});

We'll provide simple utilities that can be used for other tools:

import React from 'react';
import { mount } from 'enzyme';
import { jestKnobs } from '@storybook/addon-knobs';

import { variant1 } from './component.examples';

it('calls the callback when you click it', () => {
  const knobs = jestKnobs(variant1);
  const wrapper = mount(variant1({ knobs }));
                                                  
  wrapper.find('p').simulate('click')
  expect(knobs.name.set).toHaveBeenCalled();

  const value = 'A different value'
  knobs.name.set(value);
  expect(wrapper.find('p').text).toBe(value)
});
@tmeasday
Copy link
Author

Let's talk more about this store idea as I am not sure I really understand what the benefit you see in having the store embedded inside the story file is.

Is it purely backwards compatibility / familiarity?

We will definitely support the old storiesOf syntax for some time after releasing this new format; possibly several major releases/years.

As for familiarity I am not sure that there is a huge difference between

// something like you've suggested
export const Blue = storyGroup.create('Blue version [#123]', () => <div>a lot of markup of the story</div>);

// or the simpler
export const Blue = () => <div>a lot of markup of the story</div>;

// or even with a wrapper as mentioned above
export const Blue = story('Blue version [#123]', () => <div>a lot of markup of the story</div>);

Is there some other benefit of having the story boxed up with the component that I am not seeing?

@jantimon
Copy link

jantimon commented May 10, 2019

The store has a minor advantage and the main reason why I would like to see it in future versions:

  1. (the minor) - You can create multiple section in the same file:
export const storyGroup1 = createStoryGroup('UI|Charts/PieCharts');
// add some stories
export const storyGroup2 = createStoryGroup('UI|Charts/BarCharts');
// add some stories

However I don't believe that this is a very common case

  1. (the main reason) It allows us to identify very easily if the export is a story:
// Simplified pseudo storybook core code
import * as userStory from './a-user-story';

const exportedStoryPropertyNames = Object.keys(userStory);
const allStories = [];

// Iterate over all exports of a story file:
exportedStoryPropertyNames.forEach((exportedStoryPropertyName) => {
   // We identify if the exported property is a store 
   // (e.g. by checking for an attribute like `[storybookData]` or using instance of)
   const isStore = userStory[exportedStoryPropertyName] instanceOf StoryGroup;
   if (isStore) {
     // We can copy over all stories from the stores
     allStories.push(userStory[exportedStoryPropertyName]);
   }
});

// Optional step: Now that we have all stories and the export names we can combine the data
// This will allow us to use the title `Orange` from the following code: 
// export const Orange = storyGroup.create(() => <div>a lot of markup of the story</div>);

So the main advantage of a store is that it allows the user to export stories and unrelated data side by side.
He could export random data, components, stories for unit tests without confusing storybook.
Without a store it is hard to find out if export const Blue = () => <div>a lot of markup of the story</div>; is a story or a util function.
It also allows a title to include spaces or other characters which are not valid export names.

Here is a very basic proof of concept how that could work (based of the code above):
https://codesandbox.io/s/0m4226rpkn?fontsize=14&module=%2Fsrc%2Fexample.story.js

Story:
story

Unit test:
unit-test

@tmeasday
Copy link
Author

Hey @jantimon, we ended up solving the problem you mentioned with a white/blacklist on which exports are stories in the component metadata (includeStories/excludeStories): https://storybook.js.org/docs/formats/component-story-format/

@jantimon
Copy link

jantimon commented Nov 27, 2019

hey @tmeasday

I really like the idea how you solved it there as it keeps the main goal (writing stories) simple and edge cases like includeStories and excludeStories are possible too. 👍

What you might consider is to add an optional util to help understanding the api of the the default export:

     import {defineStories} from 'storybook';

     export default defineStories({{
        title: 'Path|To/MyComponent',
        component: MyComponent,
     });

The code for defineStories could be: defineStories(config) => config and therefore it would be totally optional.

  • Add jsdoc information to explain future maintainers / developers what is going on here:

description

  • Add typings to the defineStories helper to have auto complete on properties

autocomplete

  • Add typings to the defineStories helper to prevent typos in title

typos

What do you think?

@tmeasday
Copy link
Author

Interesting idea! What do you think @shilman?

@shilman
Copy link

shilman commented Nov 28, 2019

@tmeasday @jantimon I love it. Can we call it meta to make it consistent with the MDX Meta doc block?

@jantimon
Copy link

export default meta({ looks good to me 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment