Skip to content

Instantly share code, notes, and snippets.

@tmeasday
Last active July 18, 2017 22:00
Show Gist options
  • Save tmeasday/775c5572271121bb2a71153ac5f2cd7a to your computer and use it in GitHub Desktop.
Save tmeasday/775c5572271121bb2a71153ac5f2cd7a to your computer and use it in GitHub Desktop.

Decorator / addon preview API proposal.

Currently the principal way addons work in the preview context (ie acting on the story) is via decorators.

Types of decorator

There are 3 types of thing that the decorators do:

  • Altering the story in a very real way: "modifiers"
  • Inspecting the story, and sending telemetry somewhere (typically to the manager context via the addon channel): "inspectors"
  • Adding context to the story: "wrappers" -- this includes both:
    • CSS/Visual things like a wrapping class or background color, setting viewport size
    • React context things like redux/router providers.

Ultimately all decorators are HOCs: they take as input a story component[1] and output a decorated story component.

[1] Currently stories don't take {props, context} but they behave like stateless functional components in all other ways.

Usage of decorators

There are also 3 ways of applying a decorator:

  1. Globally (addDecorator(decorator)),
  2. Per-chapter (storiesOf(X).addDecorator(decorator))
  3. Per-story (storiesOf(X).add('name', decorator(() => <story/>))).

Configuration

It is possible to configure a decorator on a per-story basis via making it a function that takes options and returns a HOC:

// implementation
export default function myDecorator(params) {
  return (storyFn) => (context) => {
    // do stuff
    return storyFn(context); // (or wrap it, etc)
  }
}

// usage
addDecorator(myDecorator({ option: 'value'}));

Issues with the current system

I. It is the user's resposibility to apply decorators in the right order

You need to ensure all your "inspector" decorators apply before any "wrapper" decorators.

However, you might not realise that. This leads to bugs that are entirely avoidable (see below).

II. It is difficult/impossible to apply decorators in the right order

Also, suppose you are using the wrapper globally:

addDecorator(mockedI18nProvider);

There's no way to add a "per-chapter" inspector that applies before that wrapper:

storiesOf('X')
  .addDecorator(jsxDecorator); // <- this will apply *after* the i18n decorator

Conversely, if you want to add a wrapper decorator at the story level, it will apply before any existing inspectors etc.

III. per-story decorators "pollute" the story.

If your story looks like:

  .add('name', barInspector(() =>
     <FooWrapper>
       <Component />
     </FooWrapper>
  ));

It is pretty unclear which parts are integral to the story we are looking at and which parts are there for contextual or diagnostic purposes.

It would be good to separate them.

Proposal

  1. We add a third optional argument to the .add() API that takes an object of per-story options.

Adding the decorators applies one or more decorators to that story:

storiesOf('X')
  .add('name', () => <story/>, { decorators: [myDecorator, foo] });

This solves problem III.

  1. Decorators remain as HOCs, however they can optionally have a property type which indicates which sort of decorator they are:
export default function myDecorator() { ... }

myDecorator.type = 'inspector';

Decorators are applied in a special order:

  1. All "modifier" decorators (you'll probably need to closely control the order of these, but chances are they are all added at the story level)
  2. All "inspector" decorators.
  3. All "wrapper" decorators.

Within each type, they are applied in the order the user specified them.

Decorators without the property are assumed to be wrappers.

This takes the onus of ordering decorators off the user and places it on the addon writer. It solves problem I and mostly solves problem II.

  1. [OPTIONAL] We also add tools that make it really easy to create inspector decorators that post to the channel:
export default createInspectorDecorator(name, (storyElement, emit) => {
  emit(elementToJSX(storyElement));
});
@usulpro
Copy link

usulpro commented Jul 18, 2017

Maybe we can continue this discussion and use it as a base to move forward these proposals: Addons composition storybookjs/storybook#1473, Addon API storybookjs/storybook#1212

Terminology offer.

My idea is to continue this "Types of decorator" but maybe in a bit simpler way:
We can divide them to addons and decorators in the sense that:

  1. "inspectors" are addons.
  2. "modifiers" and "wrappers" are decorators

So we can say, that:

  • Addons shouldn't change the story. Addons could have some side effects like "sending telemetry".
  • Decorators don't have side effects. Decorators change story / return another story.

(additionaly: depending on what implementation of this list storybookjs/storybook#1473 (comment) we can allow addons to not return anything at all)

I think this is intuitively consistent with what users expect based on their previous experience with Storybook.

Of course, addons and decorators have the same API so it's just a terminology issue. But maybe it's worth to avoid mixed solutions in order to provide better user experience and in relation to my offer below:

Applying levels.

As it said in "Usage of decorators" there are 3 levels of applying an addon/decorator. Just want to add here that both addons and decorators could be applied at any level. And it would be nice to have the most similar APIs for each level.

(additionaly: actually we can have the similar API for all: addon/decorator/storyFn. I guess I could simplify many things. example: the offer below)

Offer for I and III issues

At this time we can say that we standardize that addon/decorator in very general is a function like withX of (storyFn, context, ...props) arguments.

My offer is that also at a very general level (without regard to any particular implementation) we can provide somehow to this function one more argument:
withX(wrappedStoryFn, context, pureStoryFn, ...props)

where the wrappedStoryFn is a story returned from previous decorators and the pureStoryFn is the first provided story by .add(...).

And so "addons" can take to process the pureStoryFn while "decorators" should take wrappedStoryFn, change it and return new wrapped story to the next "decorator".
So when you're creating an addon it's up to you what to use depending what it should be: "addon" or "decorator".

This allows users to not worry about in what order they should apply "addons". And "addons" could be applied within "decorators".

Of course, the order of "decorators" still matters!

Implementation example:
https://github.com/storybooks/storybook/pull/1473/files#diff-d93e775000f95040e90de160e5c0bb2dR23
In this PR I add to context additional field cleanStory that could be changed only by storyOf function providing the initial story. So you can choose to base your addon on storyFn which could be polluted or on pure context.cleanStory which always clean.
(also this implementation can be improved to allow "addons" to not return anything at all)

I guess some other options from this list storybookjs/storybook#1473 (comment) could provide the similar approach.

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