Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Document makeDecorators for addons authors #3831

Merged
merged 3 commits into from
Jul 4, 2018
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
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/);
});
});