diff --git a/addons/backgrounds/src/index.js b/addons/backgrounds/src/index.js index 293d82d6106b..859375d6607c 100644 --- a/addons/backgrounds/src/index.js +++ b/addons/backgrounds/src/index.js @@ -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; diff --git a/addons/info/src/index.js b/addons/info/src/index.js index 3813949ab153..b6abfae8ad9e 100644 --- a/addons/info/src/index.js +++ b/addons/info/src/index.js @@ -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; diff --git a/addons/knobs/src/index.js b/addons/knobs/src/index.js index f78acd1dffe1..6aec38d440c6 100644 --- a/addons/knobs/src/index.js +++ b/addons/knobs/src/index.js @@ -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 }; diff --git a/addons/notes/src/index.js b/addons/notes/src/index.js index 1f983df6da1a..0da32aa78715 100644 --- a/addons/notes/src/index.js +++ b/addons/notes/src/index.js @@ -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(); diff --git a/addons/viewport/src/preview/withViewport.js b/addons/viewport/src/preview/withViewport.js index a20e31f082a7..4ddb2df239b1 100644 --- a/addons/viewport/src/preview/withViewport.js +++ b/addons/viewport/src/preview/withViewport.js @@ -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 = diff --git a/docs/src/pages/addons/api/index.md b/docs/src/pages/addons/api/index.md index 40bb16a7b566..1529c9c12264 100644 --- a/docs/src/pages/addons/api/index.md +++ b/docs/src/pages/addons/api/index.md @@ -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. @@ -43,18 +41,12 @@ See how you can use this method: ```js import addonAPI from '@storybook/addons'; -const MyPanel = () => ( -
- This is a panel. -
-); +const MyPanel = () =>
This is a panel.
; // give a unique name for the panel addonAPI.addPanel('my-organisation/my-addon/panel', { title: 'My Addon', - render: () => ( - - ), + render: () => , }); ``` @@ -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: () => ( - - ), - }) -}) + render: () => , + }); +}); ``` ## Storybook API @@ -96,10 +86,7 @@ Let's say you've got a story like this: ```js import { storiesOf } from '@storybook/react'; -storiesOf('heading', module) - .add('with text', () => ( -

Hello world

- )); +storiesOf('heading', module).add('with text', () =>

Hello world

); ``` This is how you can select the above story: @@ -107,7 +94,7 @@ This is how you can select the above story: ```js addonAPI.register('my-organisation/my-addon', storybookAPI => { storybookAPI.selectStory('heading', 'with text'); -}) +}); ``` ### storybookAPI.selectInCurrentKind() @@ -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; }); ``` @@ -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', () => , { foo: 'param' })`, or `.addParameters({foo: 'param'})`). +- `allowDeprecatedUsage`: support the deprecated "wrapper" usage (`.add('story', () => withFoo(options)(() => ))`). +- `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. diff --git a/docs/src/pages/addons/writing-addons/index.md b/docs/src/pages/addons/writing-addons/index.md index 781a34ab0baf..de6d06808d98 100644 --- a/docs/src/pages/addons/writing-addons/index.md +++ b/docs/src/pages/addons/writing-addons/index.md @@ -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. @@ -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', () => ( - - - - )) - .add('with some emoji', () => ( - - - - )); + .addDecorator(withNotes) + .add('with text', () => , { + notes: 'This is a very simple Button and you can click on it.', + }) + .add( + 'with some emoji', + () => ( + + ), + { notes: 'Here we use some emoji as the Button text. Doesn't it look nice?' } + ); ``` Then it will appear in the Notes panel like this: @@ -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. @@ -122,7 +133,7 @@ class Notes extends React.Component { onAddNotes = text => { this.setState({ text }); - } + }; componentDidMount() { const { channel, api } = this.props; @@ -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, '
') : ""; + const textAfterFormatted = text ? text.trim().replace(/\n/g, '
') : ''; - return active ? - : - null; + return active ? : null; } // This is some cleanup tasks when the Notes panel is unmounting. @@ -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 }) => ( - - ), - }) -}) + render: ({ 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. @@ -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', () => ( - - - - )) - .add('with some emojies', () => ( - - - - )); + .addDecorator(withNotes) + .add('with text', () => , { + notes: 'This is a very simple Button and you can click on it.', + }) + .add( + 'with some emoji', + () => ( + + ), + { notes: 'Here we use some emoji as the Button text. Doesn't it look nice?' } + ); ``` ## Styling your addon diff --git a/lib/addons/src/make-decorator.js b/lib/addons/src/make-decorator.js index dc2669b35610..e7df28f726f1 100644 --- a/lib/addons/src/make-decorator.js +++ b/lib/addons/src/make-decorator.js @@ -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]; @@ -44,11 +45,17 @@ export const makeDecorator = ({ return decorator(...args)(...innerArgs); } - // Used to wrap a story directly .add('story', decorator(options)(() => )) - // 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)(() => )) + // 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` ); }; }; diff --git a/lib/addons/src/make-decorator.test.js b/lib/addons/src/make-decorator.test.js index e6e94ae7e742..9beb3a706511 100644 --- a/lib/addons/src/make-decorator.test.js +++ b/lib/addons/src/make-decorator.test.js @@ -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); @@ -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/); + }); });