diff --git a/addons/notes/src/__tests__/index.js b/addons/notes/src/__tests__/index.js index 611a8a2bb66e..836a8140cd82 100644 --- a/addons/notes/src/__tests__/index.js +++ b/addons/notes/src/__tests__/index.js @@ -1,7 +1,7 @@ import addons from '@storybook/addons'; import { withNotes } from '..'; -jest.mock('@storybook/addons'); +addons.getChannel = jest.fn(); describe('Storybook Addon Notes', () => { it('should inject text from `notes` parameter', () => { @@ -16,6 +16,18 @@ describe('Storybook Addon Notes', () => { expect(getStory).toHaveBeenCalledWith(context); }); + it('should NOT inject text if no `notes` parameter', () => { + const channel = { emit: jest.fn() }; + addons.getChannel.mockReturnValue(channel); + + const getStory = jest.fn(); + const context = {}; + + withNotes(getStory, context); + expect(channel.emit).not.toHaveBeenCalled(); + expect(getStory).toHaveBeenCalledWith(context); + }); + it('should inject markdown from `notes.markdown` parameter', () => { const channel = { emit: jest.fn() }; addons.getChannel.mockReturnValue(channel); diff --git a/addons/notes/src/index.js b/addons/notes/src/index.js index aeb02d93e254..1f983df6da1a 100644 --- a/addons/notes/src/index.js +++ b/addons/notes/src/index.js @@ -1,4 +1,4 @@ -import addons from '@storybook/addons'; +import addons, { makeDecorator } from '@storybook/addons'; import marked from 'marked'; function renderMarkdown(text, options) { @@ -6,43 +6,30 @@ function renderMarkdown(text, options) { return marked(text); } -const decorator = options => { - const channel = addons.getChannel(); - return (getStory, context) => { - const { - parameters: { notes }, - } = context; - const storyOptions = notes || options; +export const withNotes = makeDecorator({ + name: 'withNotes', + parameterName: 'notes', + skipIfNoParametersOrOptions: true, + wrapper: (getStory, context, { options, parameters }) => { + const channel = addons.getChannel(); - if (storyOptions) { - const { text, markdown, markdownOptions } = - typeof storyOptions === 'string' ? { text: storyOptions } : storyOptions; + const storyOptions = parameters || options; - if (!text && !markdown) { - throw new Error('You must set of one of `text` or `markdown` on the `notes` parameter'); - } + const { text, markdown, markdownOptions } = + typeof storyOptions === 'string' ? { text: storyOptions } : storyOptions; - channel.emit('storybook/notes/add_notes', text || renderMarkdown(markdown, markdownOptions)); + if (!text && !markdown) { + throw new Error('You must set of one of `text` or `markdown` on the `notes` parameter'); } - return getStory(context); - }; -}; + channel.emit('storybook/notes/add_notes', text || renderMarkdown(markdown, markdownOptions)); -const hoc = options => story => context => decorator(options)(story, context); + return getStory(context); + }, +}); export const withMarkdownNotes = (text, options) => - hoc({ + withNotes({ markdown: text, markdownOptions: options, }); - -export const withNotes = (...args) => { - // Used without options as .addDecorator(withNotes) - if (typeof args[0] === 'function') { - return decorator()(...args); - } - - // Input are options, ala .add('name', withNotes('note')(() => )) - return hoc(args[0]); -}; diff --git a/lib/addons/package.json b/lib/addons/package.json index 8f309148afe8..67665ae008d1 100644 --- a/lib/addons/package.json +++ b/lib/addons/package.json @@ -21,6 +21,7 @@ }, "dependencies": { "@storybook/channels": "4.0.0-alpha.6", - "global": "^4.3.2" + "global": "^4.3.2", + "util-deprecate": "^1.0.2" } } diff --git a/lib/addons/src/index.js b/lib/addons/src/index.js index 161a58ed0067..699123781027 100644 --- a/lib/addons/src/index.js +++ b/lib/addons/src/index.js @@ -2,6 +2,7 @@ import global from 'global'; export mockChannel from './storybook-channel-mock'; +export { makeDecorator } from './make-decorator'; export class AddonStore { constructor() { diff --git a/lib/addons/src/make-decorator.js b/lib/addons/src/make-decorator.js new file mode 100644 index 000000000000..09ec27b7299a --- /dev/null +++ b/lib/addons/src/make-decorator.js @@ -0,0 +1,51 @@ +import deprecate from 'util-deprecate'; + +// Create a decorator that can be used both in the (deprecated) old "hoc" style: +// .add('story', decorator(options)(() => )); +// +// And in the new, "parameterized" style: +// .addDecorator(decorator) +// .add('story', () => , { name: { parameters } }); +// +// *And* in the older, but not deprecated, "pass options to decorator" style: +// .addDecorator(decorator(options)) + +export const makeDecorator = ({ + name, + parameterName, + wrapper, + skipIfNoParametersOrOptions = false, +}) => { + const decorator = options => (getStory, context) => { + const parameters = context.parameters && context.parameters[parameterName]; + + if (skipIfNoParametersOrOptions && !options && !parameters) { + return getStory(context); + } + return wrapper(getStory, context, { + options, + parameters, + }); + }; + + return (...args) => { + // Used without options as .addDecorator(decorator) + if (typeof args[0] === 'function') { + return decorator()(...args); + } + + return (...innerArgs) => { + // Used as [.]addDecorator(decorator(options)) + if (innerArgs.length > 1) { + 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` + ); + }; + }; +}; diff --git a/lib/addons/src/make-decorator.test.js b/lib/addons/src/make-decorator.test.js new file mode 100644 index 000000000000..be41fb9a9144 --- /dev/null +++ b/lib/addons/src/make-decorator.test.js @@ -0,0 +1,106 @@ +import deprecate from 'util-deprecate'; +import { makeDecorator } from './make-decorator'; +import { defaultDecorateStory } from '../../../lib/core/src/client/preview/client_api'; + +jest.mock('util-deprecate'); +let deprecatedFns = []; +deprecate.mockImplementation((fn, warning) => { + const deprecatedFn = jest.fn(fn); + deprecatedFns.push({ + deprecatedFn, + warning, + }); + return deprecatedFn; +}); + +describe('makeDecorator', () => { + it('returns a decorator that passes parameters on the parameters argument', () => { + const wrapper = jest.fn(); + const decorator = makeDecorator({ wrapper, name: 'test', parameterName: 'test' }); + const story = jest.fn(); + const decoratedStory = defaultDecorateStory(story, [decorator]); + + const context = { parameters: { test: 'test-val' } }; + decoratedStory(context); + + expect(wrapper).toHaveBeenCalledWith(expect.any(Function), context, { parameters: 'test-val' }); + }); + + it('passes options added at decoration time', () => { + const wrapper = jest.fn(); + const decorator = makeDecorator({ wrapper, name: 'test', parameterName: 'test' }); + const story = jest.fn(); + const options = 'test-val'; + const decoratedStory = defaultDecorateStory(story, [decorator(options)]); + + const context = {}; + decoratedStory(context); + + expect(wrapper).toHaveBeenCalledWith(expect.any(Function), context, { options: 'test-val' }); + }); + + it('passes both options *and* parameters at the same time', () => { + const wrapper = jest.fn(); + const decorator = makeDecorator({ wrapper, name: 'test', parameterName: 'test' }); + const story = jest.fn(); + const options = 'test-val'; + const decoratedStory = defaultDecorateStory(story, [decorator(options)]); + + const context = { parameters: { test: 'test-val' } }; + decoratedStory(context); + + expect(wrapper).toHaveBeenCalledWith(expect.any(Function), context, { + options: 'test-val', + parameters: 'test-val', + }); + }); + + it('passes nothing if neither are supplied', () => { + const wrapper = jest.fn(); + const decorator = makeDecorator({ wrapper, name: 'test', parameterName: 'test' }); + const story = jest.fn(); + const decoratedStory = defaultDecorateStory(story, [decorator]); + + const context = {}; + decoratedStory(context); + + expect(wrapper).toHaveBeenCalledWith(expect.any(Function), context, {}); + }); + + it('calls the story directly if neither are supplied and skipIfNoParametersOrOptions is true', () => { + const wrapper = jest.fn(); + const decorator = makeDecorator({ + wrapper, + name: 'test', + parameterName: 'test', + skipIfNoParametersOrOptions: true, + }); + const story = jest.fn(); + const decoratedStory = defaultDecorateStory(story, [decorator]); + + const context = {}; + decoratedStory(context); + + expect(wrapper).not.toHaveBeenCalled(); + expect(story).toHaveBeenCalled(); + }); + + it('passes options added at story time, but with a deprecation warning', () => { + deprecatedFns = []; + const wrapper = jest.fn(); + const decorator = makeDecorator({ wrapper, name: 'test', parameterName: 'test' }); + const options = 'test-val'; + const story = jest.fn(); + const decoratedStory = decorator(options)(story); + expect(deprecatedFns).toHaveLength(1); + expect(deprecatedFns[0].warning).toMatch('addDecorator(test)'); + + const context = {}; + decoratedStory(context); + + expect(wrapper).toHaveBeenCalledWith(expect.any(Function), context, { + options: 'test-val', + }); + expect(deprecatedFns[0].deprecatedFn).toHaveBeenCalled(); + }); +}); diff --git a/lib/core/src/client/preview/client_api.js b/lib/core/src/client/preview/client_api.js index c63cbfd4e0af..446767f6e5e4 100644 --- a/lib/core/src/client/preview/client_api.js +++ b/lib/core/src/client/preview/client_api.js @@ -4,7 +4,7 @@ import { logger } from '@storybook/client-logger'; import StoryStore from './story_store'; -const defaultDecorateStory = (getStory, decorators) => +export const defaultDecorateStory = (getStory, decorators) => decorators.reduce( (decorated, decorator) => context => decorator(() => decorated(context), context), getStory