diff --git a/app/react/package.json b/app/react/package.json index 387573419065..fc5b93c0cd2c 100644 --- a/app/react/package.json +++ b/app/react/package.json @@ -46,6 +46,7 @@ "@babel/preset-react": "^7.12.10", "@pmmmwh/react-refresh-webpack-plugin": "^0.4.2", "@storybook/addons": "6.2.0-alpha.25", + "@storybook/client-api": "6.2.0-alpha.25", "@storybook/core": "6.2.0-alpha.25", "@storybook/node-logger": "6.2.0-alpha.25", "@storybook/semver": "^7.3.2", @@ -66,7 +67,6 @@ "webpack": "^4.46.0" }, "devDependencies": { - "@storybook/client-api": "6.2.0-alpha.25", "@types/node": "^14.14.20", "@types/prompts": "^2.0.9", "@types/webpack": "^4.41.26" diff --git a/app/react/src/client/index.ts b/app/react/src/client/index.ts index 8f2cf30cc646..8385448246c4 100644 --- a/app/react/src/client/index.ts +++ b/app/react/src/client/index.ts @@ -11,6 +11,7 @@ export { } from './preview'; export * from './preview/types-6-0'; +export * from './preview/utils'; if (module && module.hot && module.hot.decline) { module.hot.decline(); diff --git a/app/react/src/client/preview/types-6-0.ts b/app/react/src/client/preview/types-6-0.ts index 4a81e829f0c2..172847586278 100644 --- a/app/react/src/client/preview/types-6-0.ts +++ b/app/react/src/client/preview/types-6-0.ts @@ -1,5 +1,13 @@ import { ComponentType } from 'react'; -import { Args as DefaultArgs, Annotations, BaseMeta, BaseStory } from '@storybook/addons'; +import { + Args as DefaultArgs, + Annotations, + ArgTypes, + Parameters, + BaseMeta, + BaseStory, + BaseDecorators, +} from '@storybook/addons'; import { StoryFnReactReturnType } from './types'; export type { Args, ArgTypes, Parameters, StoryContext } from '@storybook/addons'; @@ -22,3 +30,16 @@ export type Meta = BaseMeta & */ export type Story = BaseStory & Annotations; + +/** + * Object representing the preview.ts module + * + * Used in storybook testing utilities. + * @see [Unit testing with Storybook](https://storybook.js.org/docs/react/workflows/unit-testing) + */ +export type GlobalConfig = { + decorators?: BaseDecorators; + parameters?: Parameters; + argTypes?: ArgTypes; + [key: string]: any; +}; diff --git a/app/react/src/client/preview/utils.ts b/app/react/src/client/preview/utils.ts new file mode 100644 index 000000000000..c8739c86887f --- /dev/null +++ b/app/react/src/client/preview/utils.ts @@ -0,0 +1,133 @@ +import { defaultDecorateStory, combineParameters } from '@storybook/client-api'; +import { GlobalConfig, Meta, Story, StoryContext } from './types-6-0'; + +let globalStorybookConfig = {}; + +/** Function that sets the globalConfig of your storybook. The global config is the preview module of your .storybook folder. + * + * It should be run a single time, so that your global config (e.g. decorators) is applied to your stories when using `composeStories` or `composeStory`. + * + * Example: + *```jsx + * // setup.js (for jest) + * import { setGlobalConfig } from '@storybook/react'; + * import * as globalStorybookConfig from './.storybook/preview'; + * + * setGlobalConfig(globalStorybookConfig); + *``` + * + * @param config - e.g. (import * as globalConfig from '../.storybook/preview') + */ +export function setGlobalConfig(config: GlobalConfig) { + globalStorybookConfig = config; +} + +/** + * Function that will receive a story along with meta (e.g. a default export from a .stories file) + * and optionally a globalConfig e.g. (import * from '../.storybook/preview) + * and will return a composed component that has all args/parameters/decorators/etc combined and applied to it. + * + * + * It's very useful for reusing a story in scenarios outside of Storybook like unit testing. + * + * Example: + *```jsx + * import { render } from '@testing-library/react'; + * import { composeStory } from '@storybook/react'; + * import Meta, { Primary as PrimaryStory } from './Button.stories'; + * + * const Primary = composeStory(PrimaryStory, Meta); + * + * test('renders primary button with Hello World', () => { + * const { getByText } = render(Hello world); + * expect(getByText(/Hello world/i)).not.toBeNull(); + * }); + *``` + * + * @param story + * @param meta - e.g. (import Meta from './Button.stories') + * @param [globalConfig] - e.g. (import * as globalConfig from '../.storybook/preview') this can be applied automatically if you use `setGlobalConfig` in your setup files. + */ +export function composeStory( + story: Story, + meta: Meta, + globalConfig: GlobalConfig = globalStorybookConfig +) { + const finalStoryFn = (context: StoryContext) => { + const { passArgsFirst = true } = context.parameters; + if (!passArgsFirst) { + throw new Error( + 'composeStory does not support legacy style stories (with passArgsFirst = false).' + ); + } + return story(context.args as GenericArgs, context); + }; + + const combinedDecorators = [ + ...(story.decorators || story.story?.decorators || []), + ...(meta?.decorators || []), + ...(globalConfig?.decorators || []), + ]; + + const decorated = defaultDecorateStory(finalStoryFn, combinedDecorators); + + return ((extraArgs: Record) => + decorated({ + id: '', + kind: '', + name: '', + argTypes: globalConfig.argTypes, + globals: globalConfig.globalTypes, + parameters: combineParameters( + globalConfig.parameters || {}, + meta.parameters || {}, + story.story?.parameters || {}, + story.parameters || {} + ), + args: { + ...meta.args, + ...story.story?.args, + ...story.args, + ...extraArgs, + }, + })) as Story>; +} + +/** + * Function that will receive a stories import (e.g. `import * as stories from './Button.stories'`) + * and optionally a globalConfig (e.g. `import * from '../.storybook/preview`) + * and will return an object containing all the stories passed, but now as a composed component that has all args/parameters/decorators/etc combined and applied to it. + * + * + * It's very useful for reusing stories in scenarios outside of Storybook like unit testing. + * + * Example: + *```jsx + * import { render } from '@testing-library/react'; + * import { composeStories } from '@storybook/react'; + * import * as stories from './Button.stories'; + * + * const { Primary, Secondary } = composeStories(stories); + * + * test('renders primary button with Hello World', () => { + * const { getByText } = render(Hello world); + * expect(getByText(/Hello world/i)).not.toBeNull(); + * }); + *``` + * + * @param storiesImport - e.g. (import * as stories from './Button.stories') + * @param [globalConfig] - e.g. (import * as globalConfig from '../.storybook/preview') this can be applied automatically if you use `setGlobalConfig` in your setup files. + */ +export function composeStories( + storiesImport: T, + globalConfig?: GlobalConfig +) { + const { default: meta, ...stories } = storiesImport; + + // Compose an object containing all processed stories passed as parameters + return Object.entries(stories).reduce((storiesMap, [key, story]: [string, Story]) => { + // eslint-disable-next-line no-param-reassign + storiesMap[key] = composeStory(story, meta, globalConfig); + return storiesMap; + }, {} as { [key: string]: Story }) as Pick>; +} diff --git a/docs/snippets/react/button-test-utility-setup.js.mdx b/docs/snippets/react/button-test-utility-setup.js.mdx new file mode 100644 index 000000000000..755254017a7b --- /dev/null +++ b/docs/snippets/react/button-test-utility-setup.js.mdx @@ -0,0 +1,7 @@ +```js +// setupFile.js +import { setGlobalConfig } from '@storybook/react'; +import * as globalStorybookConfig from './.storybook/preview'; + +setGlobalConfig(globalStorybookConfig); +``` diff --git a/docs/snippets/react/button-test-utility.js.mdx b/docs/snippets/react/button-test-utility.js.mdx new file mode 100644 index 000000000000..b6c3596b599c --- /dev/null +++ b/docs/snippets/react/button-test-utility.js.mdx @@ -0,0 +1,16 @@ +```js +// Button.test.js + +import React from 'react'; +import { composeStories } from '@storybook/react'; +import { render, screen } from '@testing-library/react'; +import * as stories from './Button.stories'; +import '@testing-library/jest-dom/extend-expect'; + +const { Primary } = composeStories(stories); + +it('renders the button in the primary state with custom text', () => { + render(Hello world); + expect(screen.getByRole('button')).toHaveTextContent('Hello world'); +}); +``` diff --git a/docs/workflows/unit-testing.md b/docs/workflows/unit-testing.md index bb922a6d62ae..c26522c94482 100644 --- a/docs/workflows/unit-testing.md +++ b/docs/workflows/unit-testing.md @@ -22,4 +22,34 @@ Here is an example of how you can use it in a testing library: +## Testing more complex components + +When your components use functionality from tools like [styled components](http://styled-components.com/) or [redux](https://redux.js.org/), it's necessary to wrap your stories in [decorators](../writing-stories/decorators#story-decorators) so that the extra functionality is available in your stories. If that is the case, you can use a testing utility (currently only available in Storybook for React) to reuse your stories in your tests, and all the decorators, parameters, and args of that story will be automatically applied in the component. This makes your test file look quite clean and just reuse the configuration done in the Storybook side, making the maintenance of your testing setup simpler. + +Here is an example of how you can use it in a testing library: + + + + + + + +In case you have a global configuration for your stories (such as decorators in your preview.js file), they won't be picked up automatically in the code above. For that, you need to tell Storybook what is your global configuration, before running any code with `composeStories`: + + + + + + + +If you're using jest, you can configure on a [setup file](https://jestjs.io/docs/en/configuration#setupfiles-array), otherwise, just make sure the configuration runs only once and before you execute `composeStories`. + Unit tests can be brittle and expensive to maintain for _every_ component. We recommend combining unit tests with other testing methods like [visual regression testing](./visual-testing.md) for comprehensive coverage with less maintenance work. diff --git a/examples/cra-ts-essentials/.storybook/preview.js b/examples/cra-ts-essentials/.storybook/preview.tsx similarity index 69% rename from examples/cra-ts-essentials/.storybook/preview.js rename to examples/cra-ts-essentials/.storybook/preview.tsx index 305c8eb0be14..cbec61dc651f 100644 --- a/examples/cra-ts-essentials/.storybook/preview.js +++ b/examples/cra-ts-essentials/.storybook/preview.tsx @@ -1,9 +1,17 @@ +import { Story, DecoratorFn } from '@storybook/react'; import React from 'react'; -export const decorators = [ - (StoryFn, { globals: { locale = 'en' } }) => ( +export const decorators: DecoratorFn[] = [ + ( + StoryFn: Story, + { + globals: { + locale: { value = 'en' }, + }, + } + ) => ( <> -
{locale}
+
{value}
), diff --git a/examples/cra-ts-essentials/package.json b/examples/cra-ts-essentials/package.json index 22e5cc6f41f5..c4b9ee0826cb 100644 --- a/examples/cra-ts-essentials/package.json +++ b/examples/cra-ts-essentials/package.json @@ -8,7 +8,7 @@ "eject": "react-scripts eject", "start": "react-scripts start", "storybook": "start-storybook -p 9009 -s public", - "test": "react-scripts test" + "test": "react-scripts test --setupFiles ./setup.ts" }, "browserslist": { "production": [ diff --git a/examples/cra-ts-essentials/setup.ts b/examples/cra-ts-essentials/setup.ts new file mode 100644 index 000000000000..181f8cefb262 --- /dev/null +++ b/examples/cra-ts-essentials/setup.ts @@ -0,0 +1,4 @@ +import { setGlobalConfig } from '@storybook/react'; +import * as globalStorybookConfig from './.storybook/preview'; + +setGlobalConfig(globalStorybookConfig); diff --git a/examples/cra-ts-essentials/src/stories/1-Button.stories.tsx b/examples/cra-ts-essentials/src/stories/1-Button.stories.tsx deleted file mode 100644 index e952509a9012..000000000000 --- a/examples/cra-ts-essentials/src/stories/1-Button.stories.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import React from 'react'; -import { Button } from '@storybook/react/demo'; - -export default { - title: 'Button', - component: Button, - argTypes: { onClick: { action: 'clicked' } }, -}; - -const Template = (args: any) => ); }; diff --git a/examples/cra-ts-essentials/tsconfig.json b/examples/cra-ts-essentials/tsconfig.json index 22372a535100..dd2e20fa66b3 100644 --- a/examples/cra-ts-essentials/tsconfig.json +++ b/examples/cra-ts-essentials/tsconfig.json @@ -5,6 +5,7 @@ "noImplicitAny": true, "experimentalDecorators": true, "emitDecoratorMetadata": true, + "strictBindCallApply": true, "jsx": "react", "module": "commonjs", "skipDefaultLibCheck": true, diff --git a/lib/addons/src/types.ts b/lib/addons/src/types.ts index cc8f37266016..1e9e54f0b950 100644 --- a/lib/addons/src/types.ts +++ b/lib/addons/src/types.ts @@ -257,9 +257,19 @@ export interface BaseMeta { export interface BaseStory { (args: Args, context: StoryContext): StoryFnReturnType; - /** * Override the display name in the UI */ storyName?: string; + /** + * @deprecated + * Object-styled story annotation + * @see [Migrating to hoist Story Annotations](https://github.com/storybookjs/storybook/blob/next/lib/codemod/README.md#csf-hoist-story-annotations) + */ + story?: Annotations; + /** + * Dynamic data that are passed into stories (component examples) by Storybook addons + * @see [Pass args first parameter](https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#args-passed-as-first-argument-to-story) + */ + args?: Partial; }