Skip to content

Commit

Permalink
Merge pull request #3831 from storybooks/tmeasday/document-parameters…
Browse files Browse the repository at this point in the history
…-for-addons

Document `makeDecorators` for addons authors
  • Loading branch information
Hypnosphi authored Jul 4, 2018
2 parents 466b8df + 96667b5 commit dbf243d
Show file tree
Hide file tree
Showing 9 changed files with 137 additions and 78 deletions.
1 change: 1 addition & 0 deletions addons/backgrounds/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const withBackgrounds = makeDecorator({
name: 'backgrounds',
parameterName: 'backgrounds',
skipIfNoParametersOrOptions: true,
allowDeprecatedUsage: true,
wrapper: (getStory, context, { options, parameters }) => {
const backgrounds = parameters || options;

Expand Down
1 change: 1 addition & 0 deletions addons/info/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ function addInfo(storyFn, context, infoOptions) {
export const withInfo = makeDecorator({
name: 'withInfo',
parameterName: 'info',
allowDeprecatedUsage: true,
wrapper: (getStory, context, { options, parameters }) => {
const storyOptions = parameters || options;
const infoOptions = typeof storyOptions === 'string' ? { text: storyOptions } : storyOptions;
Expand Down
1 change: 1 addition & 0 deletions addons/knobs/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export const withKnobs = makeDecorator({
name: 'withKnobs',
parameterName: 'knobs',
skipIfNoParametersOrOptions: false,
allowDeprecatedUsage: true,
wrapper: (getStory, context, { options, parameters }) => {
const storyOptions = parameters || options;
const allOptions = { ...defaultOptions, ...storyOptions };
Expand Down
1 change: 1 addition & 0 deletions addons/notes/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const withNotes = makeDecorator({
name: 'withNotes',
parameterName: 'notes',
skipIfNoParametersOrOptions: true,
allowDeprecatedUsage: true,
wrapper: (getStory, context, { options, parameters }) => {
const channel = addons.getChannel();

Expand Down
1 change: 1 addition & 0 deletions addons/viewport/src/preview/withViewport.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const applyViewportOptions = (options = {}) => {
const withViewport = makeDecorator({
name: 'withViewport',
parameterName: 'viewport',
allowDeprecatedUsage: true,
wrapper: (getStory, context, { options, parameters }) => {
const storyOptions = parameters || options;
const viewportOptions =
Expand Down
61 changes: 39 additions & 22 deletions docs/src/pages/addons/api/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,7 @@ See how we can use this:
import addonAPI from '@storybook/addons';

// Register the addon with a unique name.
addonAPI.register('my-organisation/my-addon', storybookAPI => {

});
addonAPI.register('my-organisation/my-addon', storybookAPI => {});
```

Now you'll get an instance to our StorybookAPI. See the [api docs](/addons/api#storybook-api) for Storybook API regarding using that.
Expand All @@ -43,18 +41,12 @@ See how you can use this method:
```js
import addonAPI from '@storybook/addons';

const MyPanel = () => (
<div>
This is a panel.
</div>
);
const MyPanel = () => <div>This is a panel.</div>;

// give a unique name for the panel
addonAPI.addPanel('my-organisation/my-addon/panel', {
title: 'My Addon',
render: () => (
<MyPanel />
),
render: () => <MyPanel />,
});
```

Expand All @@ -71,11 +63,9 @@ addonAPI.register('my-organisation/my-addon', storybookAPI => {
// Also need to set a unique name to the panel.
addonAPI.addPanel('my-organisation/my-addon/panel', {
title: 'Notes',
render: () => (
<Notes channel={addonAPI.getChannel()} api={storybookAPI}/>
),
})
})
render: () => <Notes channel={addonAPI.getChannel()} api={storybookAPI} />,
});
});
```

## Storybook API
Expand All @@ -96,18 +86,15 @@ Let's say you've got a story like this:
```js
import { storiesOf } from '@storybook/react';

storiesOf('heading', module)
.add('with text', () => (
<h1>Hello world</h1>
));
storiesOf('heading', module).add('with text', () => <h1>Hello world</h1>);
```

This is how you can select the above story:

```js
addonAPI.register('my-organisation/my-addon', storybookAPI => {
storybookAPI.selectStory('heading', 'with text');
})
});
```

### storybookAPI.selectInCurrentKind()
Expand Down Expand Up @@ -161,7 +148,7 @@ This method allows you to get application url state with some changed params. Fo
addonAPI.register('my-organisation/my-addon', storybookAPI => {
const href = storybookAPI.getUrlState({
selectedKind: 'kind',
selectedStory: 'story'
selectedStory: 'story',
}).url;
});
```
Expand All @@ -175,3 +162,33 @@ addonAPI.register('my-organisation/my-addon', storybookAPI => {
storybookAPI.onStory((kind, story) => console.log(kind, story));
});
```

## `makeDecorator` API

The `makeDecorator` API can be used to create decorators in the style of the official addons easily. Use it like so:

```js
import { makeDecorator } from '@storybook/addons';

export makeDecorator({
name: 'withSomething',
parameterName: 'something',
wrapper: (storyFn, context, { parameters }) => {
// Do something with `parameters`, which are set via { something: ... }

// Note you may alter the story output if you like, although generally that's
// not advised
return storyFn(context);
}
})
```

The options to `makeDecorator` are:

- `name`: The name of the export (e.g. `withNotes`)
- `parameterName`: The name of the parameter your addon uses. This should be unique.
- `skipIfNoParametersOrOptions`: Don't run your decorator if the user hasn't set options (via `.addDecorator(withFoo(options)))`) or parameters (`.add('story', () => <Story/>, { foo: 'param' })`, or `.addParameters({foo: 'param'})`).
- `allowDeprecatedUsage`: support the deprecated "wrapper" usage (`.add('story', () => withFoo(options)(() => <Story/>))`).
- `wrapper`: your decorator function. Takes the `storyFn`, `context`, and both the `options` and `parameters` (as defined in `skipIfNoParametersOrOptions` above).

Note if the parameters to a story include `{ foo: {disable: true } }` (where `foo` is the `parameterName` of your addon), your decorator will note be called.
110 changes: 61 additions & 49 deletions docs/src/pages/addons/writing-addons/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@ As shown in the above image, there's a communication channel that the Manager Ap

With an addon, you can add more functionality to Storybook. Here are a few things you could do:

- Add a panel to Storybook (like Action Logger).
- Interact with the story and the panel.
- Set and get URL query params.
- Select a story.
- Register keyboard shortcuts (coming soon).
- Add a panel to Storybook (like Action Logger).
- Interact with the story and the panel.
- Set and get URL query params.
- Select a story.
- Register keyboard shortcuts (coming soon).

With this, you can write some pretty cool addons. Look at our [Addon gallery](/addons/addon-gallery) to have a look at some sample addons.

Expand All @@ -43,21 +43,26 @@ We write a story for our addon like this:
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { WithNotes } from '../notes-addon';
import withNotes from '../notes-addon';

import Button from './Button';

storiesOf('Button', module)
.add('with text', () => (
<WithNotes notes={'This is a very simple Button and you can click on it.'}>
<Button onClick={action('clicked')}>Hello Button</Button>
</WithNotes>
))
.add('with some emoji', () => (
<WithNotes notes={'Here we use some emoji as the Button text. Doesn&apos;t it look nice?'}>
<Button onClick={action('clicked')}><span role="img" aria-label="so cool">😀 😎 👍 💯</span></Button>
</WithNotes>
));
.addDecorator(withNotes)
.add('with text', () => <Button onClick={action('clicked')}>Hello Button</Button>, {
notes: 'This is a very simple Button and you can click on it.',
})
.add(
'with some emoji',
() => (
<Button onClick={action('clicked')}>
<span role="img" aria-label="so cool">
😀 😎 👍 💯
</span>
</Button>
),
{ notes: 'Here we use some emoji as the Button text. Doesn&apos;t it look nice?' }
);
```

Then it will appear in the Notes panel like this:
Expand All @@ -79,23 +84,29 @@ Now we need to create two files, `register.js` and `index.js,` inside a director

## The Addon

Let's add the following content to the `index.js`. It will expose a class called `WithNotes`, which wraps our story.
Let's add the following content to the `index.js`. It will expose a decorator called `withNotes` which we use the `.addDecorator()` API to decorate all our stories.

The `@storybook/addons` package contains a `makeDecorator` function which we can easily use to create such a decorator:

```js
import React from 'react';
import addons from '@storybook/addons';

export class WithNotes extends React.Component {
render() {
const { children, notes } = this.props;
import addons, { makeDecorator } from '@storybook/addons';

export withNotes = makeDecorator({
name: 'withNotes',
parameterName: 'notes',
// This means don't run this decorator if the notes decorator is not set
skipIfNoParametersOrOptions: true,
wrapper: (getStory, context, {parameters}) => {
const channel = addons.getChannel();

// send the notes to the channel.
channel.emit('MYADDON/add_notes', notes);
// return children elements.
return children;
// Our simple API above simply sets the notes parameter to a string,
// which we send to the channel
channel.emit('MYADDON/add_notes', parameters);

return story(context);
}
}
})
```

In this case, our component can access something called the channel. It lets us communicate with the panel (where we display notes). It has a NodeJS [EventEmitter](https://nodejs.org/api/events.html) compatible API.
Expand All @@ -122,7 +133,7 @@ class Notes extends React.Component {

onAddNotes = text => {
this.setState({ text });
}
};

componentDidMount() {
const { channel, api } = this.props;
Expand All @@ -138,11 +149,9 @@ class Notes extends React.Component {
render() {
const { text } = this.state;
const { active } = this.props;
const textAfterFormatted = text? text.trim().replace(/\n/g, '<br />') : "";
const textAfterFormatted = text ? text.trim().replace(/\n/g, '<br />') : '';

return active ?
<NotesPanel dangerouslySetInnerHTML={{ __html: textAfterFormatted }} /> :
null;
return active ? <NotesPanel dangerouslySetInnerHTML={{ __html: textAfterFormatted }} /> : null;
}

// This is some cleanup tasks when the Notes panel is unmounting.
Expand All @@ -158,15 +167,13 @@ class Notes extends React.Component {
}

// Register the addon with a unique name.
addons.register('MYADDON', (api) => {
addons.register('MYADDON', api => {
// Also need to set a unique name to the panel.
addons.addPanel('MYADDON/panel', {
title: 'Notes',
render: ({ active }) => (
<Notes channel={addons.getChannel()} api={api} active={active} />
),
})
})
render: ({ active }) => <Notes channel={addons.getChannel()} api={api} active={active} />,
});
});
```

It will register our addon and add a panel. In this case, the panel represents a React component called `Notes`. That component has access to the channel and storybook api.
Expand Down Expand Up @@ -199,21 +206,26 @@ That's it. Now you can create notes for any story as shown below:
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { WithNotes } from '../notes-addon';
import withNotes from '../notes-addon';

import Button from './Button';

storiesOf('Button', module)
.add('with text', () => (
<WithNotes notes={'This is a very simple Button and you can click on it.'}>
<Button onClick={action('clicked')}>Hello Button</Button>
</WithNotes>
))
.add('with some emojies', () => (
<WithNotes notes={'Here we use emojies as the Button text. Doesn&apos;t it look nice?'}>
<Button onClick={action('clicked')}><span role="img" aria-label="so cool">😀 😎 👍 💯</span></Button>
</WithNotes>
));
.addDecorator(withNotes)
.add('with text', () => <Button onClick={action('clicked')}>Hello Button</Button>, {
notes: 'This is a very simple Button and you can click on it.',
})
.add(
'with some emoji',
() => (
<Button onClick={action('clicked')}>
<span role="img" aria-label="so cool">
😀 😎 👍 💯
</span>
</Button>
),
{ notes: 'Here we use some emoji as the Button text. Doesn&apos;t it look nice?' }
);
```

## Styling your addon
Expand Down
17 changes: 12 additions & 5 deletions lib/addons/src/make-decorator.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const makeDecorator = ({
parameterName,
wrapper,
skipIfNoParametersOrOptions = false,
allowDeprecatedUsage = false,
}) => {
const decorator = options => (getStory, context) => {
const parameters = context.parameters && context.parameters[parameterName];
Expand Down Expand Up @@ -44,11 +45,17 @@ export const makeDecorator = ({
return decorator(...args)(...innerArgs);
}

// Used to wrap a story directly .add('story', decorator(options)(() => <Story />))
// This is now deprecated:
return deprecate(
context => decorator(...args)(innerArgs[0], context),
`Passing stories directly into ${name}() is deprecated, instead use addDecorator(${name}) and pass options with the '${parameterName}' parameter`
if (allowDeprecatedUsage) {
// Used to wrap a story directly .add('story', decorator(options)(() => <Story />))
// This is now deprecated:
return deprecate(
context => decorator(...args)(innerArgs[0], context),
`Passing stories directly into ${name}() is deprecated, instead use addDecorator(${name}) and pass options with the '${parameterName}' parameter`
);
}

throw new Error(
`Passing stories directly into ${name}() is not allowed, instead use addDecorator(${name}) and pass options with the '${parameterName}' parameter`
);
};
};
Expand Down
22 changes: 20 additions & 2 deletions lib/addons/src/make-decorator.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,10 +103,15 @@ describe('makeDecorator', () => {
expect(story).toHaveBeenCalled();
});

it('passes options added at story time, but with a deprecation warning', () => {
it('passes options added at story time, but with a deprecation warning, if allowed', () => {
deprecatedFns = [];
const wrapper = jest.fn();
const decorator = makeDecorator({ wrapper, name: 'test', parameterName: 'test' });
const decorator = makeDecorator({
wrapper,
name: 'test',
parameterName: 'test',
allowDeprecatedUsage: true,
});
const options = 'test-val';
const story = jest.fn();
const decoratedStory = decorator(options)(story);
Expand All @@ -121,4 +126,17 @@ describe('makeDecorator', () => {
});
expect(deprecatedFns[0].deprecatedFn).toHaveBeenCalled();
});

it('throws if options are added at storytime, if not allowed', () => {
const wrapper = jest.fn();
const decorator = makeDecorator({
wrapper,
name: 'test',
parameterName: 'test',
allowDeprecatedUsage: false,
});
const options = 'test-val';
const story = jest.fn();
expect(() => decorator(options)(story)).toThrow(/not allowed/);
});
});

0 comments on commit dbf243d

Please sign in to comment.