From eb28dad8d0506e1ef73c109ea4eef636f781d936 Mon Sep 17 00:00:00 2001 From: Yann Braga Date: Thu, 29 Feb 2024 11:00:53 +0100 Subject: [PATCH 01/11] Portable stories: Make canvasElement optional in the play function --- MIGRATION.md | 76 +++++++++--- code/lib/preview-api/src/index.ts | 1 + .../store/csf/portable-stories.test.ts | 65 ++++++---- .../src/modules/store/csf/portable-stories.ts | 102 ++++++++++++---- code/lib/types/src/modules/composedStory.ts | 30 ++--- .../portable-stories.test.tsx.snap | 115 ++++++++++++------ .../src/__test__/portable-stories.test.tsx | 15 ++- ...rtable-stories.ts => portable-stories.tsx} | 11 ++ .../portable-stories.test.ts.snap | 106 ++++++++++------ .../composeStories/portable-stories.test.ts | 4 +- code/renderers/vue3/src/portable-stories.ts | 17 ++- 11 files changed, 378 insertions(+), 164 deletions(-) rename code/renderers/react/src/{portable-stories.ts => portable-stories.tsx} (94%) diff --git a/MIGRATION.md b/MIGRATION.md index 202962c2cf7f..c51ddba6347e 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,7 +1,9 @@

Migration

- [From version 7.x to 8.0.0](#from-version-7x-to-800) - - [Type change in `composeStories` API](#type-change-in-composestories-api) + - [Portable stories](#portable-stories) + - [Type change in `composeStories` API](#type-change-in-composestories-api) + - [The context in the play function is now optional](#the-context-in-the-play-function-is-now-optional) - [Tab addons are now routed to a query parameter](#tab-addons-are-now-routed-to-a-query-parameter) - [Default keyboard shortcuts changed](#default-keyboard-shortcuts-changed) - [Manager addons are now rendered with React 18](#manager-addons-are-now-rendered-with-react-18) @@ -86,17 +88,17 @@ - [Tab addons cannot manually route, Tool addons can filter their visibility via tabId](#tab-addons-cannot-manually-route-tool-addons-can-filter-their-visibility-via-tabid) - [Removed `config` preset](#removed-config-preset-1) - [From version 7.5.0 to 7.6.0](#from-version-750-to-760) - - [CommonJS with Vite is deprecated](#commonjs-with-vite-is-deprecated) - - [Using implicit actions during rendering is deprecated](#using-implicit-actions-during-rendering-is-deprecated) - - [typescript.skipBabel deprecated](#typescriptskipbabel-deprecated) - - [Primary doc block accepts of prop](#primary-doc-block-accepts-of-prop) - - [Addons no longer need a peer dependency on React](#addons-no-longer-need-a-peer-dependency-on-react) + - [CommonJS with Vite is deprecated](#commonjs-with-vite-is-deprecated) + - [Using implicit actions during rendering is deprecated](#using-implicit-actions-during-rendering-is-deprecated) + - [typescript.skipBabel deprecated](#typescriptskipbabel-deprecated) + - [Primary doc block accepts of prop](#primary-doc-block-accepts-of-prop) + - [Addons no longer need a peer dependency on React](#addons-no-longer-need-a-peer-dependency-on-react) - [From version 7.4.0 to 7.5.0](#from-version-740-to-750) - - [`storyStoreV6` and `storiesOf` is deprecated](#storystorev6-and-storiesof-is-deprecated) - - [`storyIndexers` is replaced with `experimental_indexers`](#storyindexers-is-replaced-with-experimental_indexers) + - [`storyStoreV6` and `storiesOf` is deprecated](#storystorev6-and-storiesof-is-deprecated) + - [`storyIndexers` is replaced with `experimental_indexers`](#storyindexers-is-replaced-with-experimental_indexers) - [From version 7.0.0 to 7.2.0](#from-version-700-to-720) - - [Addon API is more type-strict](#addon-api-is-more-type-strict) - - [Addon-controls hideNoControlsWarning parameter is deprecated](#addon-controls-hidenocontrolswarning-parameter-is-deprecated) + - [Addon API is more type-strict](#addon-api-is-more-type-strict) + - [Addon-controls hideNoControlsWarning parameter is deprecated](#addon-controls-hidenocontrolswarning-parameter-is-deprecated) - [From version 6.5.x to 7.0.0](#from-version-65x-to-700) - [7.0 breaking changes](#70-breaking-changes) - [Dropped support for Node 15 and below](#dropped-support-for-node-15-and-below) @@ -122,7 +124,7 @@ - [Deploying build artifacts](#deploying-build-artifacts) - [Dropped support for file URLs](#dropped-support-for-file-urls) - [Serving with nginx](#serving-with-nginx) - - [Ignore story files from node\_modules](#ignore-story-files-from-node_modules) + - [Ignore story files from node_modules](#ignore-story-files-from-node_modules) - [7.0 Core changes](#70-core-changes) - [7.0 feature flags removed](#70-feature-flags-removed) - [Story context is prepared before for supporting fine grained updates](#story-context-is-prepared-before-for-supporting-fine-grained-updates) @@ -136,7 +138,7 @@ - [Addon-interactions: Interactions debugger is now default](#addon-interactions-interactions-debugger-is-now-default) - [7.0 Vite changes](#70-vite-changes) - [Vite builder uses Vite config automatically](#vite-builder-uses-vite-config-automatically) - - [Vite cache moved to node\_modules/.cache/.vite-storybook](#vite-cache-moved-to-node_modulescachevite-storybook) + - [Vite cache moved to node_modules/.cache/.vite-storybook](#vite-cache-moved-to-node_modulescachevite-storybook) - [7.0 Webpack changes](#70-webpack-changes) - [Webpack4 support discontinued](#webpack4-support-discontinued) - [Babel mode v7 exclusively](#babel-mode-v7-exclusively) @@ -186,7 +188,7 @@ - [Dropped addon-docs manual babel configuration](#dropped-addon-docs-manual-babel-configuration) - [Dropped addon-docs manual configuration](#dropped-addon-docs-manual-configuration) - [Autoplay in docs](#autoplay-in-docs) - - [Removed STORYBOOK\_REACT\_CLASSES global](#removed-storybook_react_classes-global) + - [Removed STORYBOOK_REACT_CLASSES global](#removed-storybook_react_classes-global) - [7.0 Deprecations and default changes](#70-deprecations-and-default-changes) - [storyStoreV7 enabled by default](#storystorev7-enabled-by-default) - [`Story` type deprecated](#story-type-deprecated) @@ -401,7 +403,9 @@ ## From version 7.x to 8.0.0 -### Type change in `composeStories` API +### Portable stories + +#### Type change in `composeStories` API There is a TypeScript type change in the `play` function returned from `composeStories` or `composeStory` in `@storybook/react` or `@storybook/vue3`, where before it was always defined, now it is potentially undefined. This means that you might have to make a small change in your code, such as: @@ -418,6 +422,49 @@ await Primary.play!(...) // if you want a runtime error when the play function d There are plans to make the type of the play function be inferred based on your imported story's play function in a near future, so the types will be 100% accurate. +#### The context in the play function is now optional + +When reusing a story that has a play function, you don't have to pass the context anymore, not even the `canvasElement`. The context is built-in and if you don't pass overrides, it will still be present. It is still possible to pass overrides to the context, if you'd like. + +```tsx +const { Primary } = composeStories(stories); +test("load and render", async () => { + const { container } = render(); + // before: + await Primary.play({ canvasElement: container, ...ArgsOrWhateverElse }); + + // after: + await Primary.play(); +}); +``` + +In order for this to be possible, the portable stories API now adds a wrapper to your stories with a unique id based on your story id, such as: + +```html +
+ +
+``` + +This means that if you take DOM snapshots of your stories, they will be affected and you will have to update them. + +The id calculation is based on different heuristics based on your Meta title and Story name. When using `composeStories`, the id can be inferred automatically. However, when using `composeStory` and your story does not explicitly have a `storyName` property, the story name can't be inferred automatically. As a result, its name will be "Unnamed Story", resulting in a wrapper id like `"#storybook-story-button--unnamed-story"`. If the id matters to you and you want to fix it, you have to specify the `exportsName` property like so: + +```ts +test("snapshots the story with custom id", () => { + const Primary = composeStory( + stories.Primary, + stories.default, + undefined, + // If you do not want the `unnamed-story` id, you have to pass the name of the story as a parameter + "Primary" + ); + + const { baseElement } = render(); + expect(baseElement).toMatchSnapshot(); +}); +``` + ### Tab addons are now routed to a query parameter The URL of a tab used to be: `http://localhost:6006/?path=/my-addon-tab/my-story`. @@ -556,7 +603,6 @@ This means https://github.com/IanVS/vite-plugin-turbosnap is no longer necessary Now that both Vite and Webpack support the `preview-stats.json` file, the flag has been renamed. The old flag will continue to work. - ### Implicit actions can not be used during rendering (for example in the play function) In Storybook 7, we inferred if the component accepts any action props, diff --git a/code/lib/preview-api/src/index.ts b/code/lib/preview-api/src/index.ts index e47cdaa0a0dd..63d45114dc23 100644 --- a/code/lib/preview-api/src/index.ts +++ b/code/lib/preview-api/src/index.ts @@ -56,6 +56,7 @@ export { filterArgTypes, sanitizeStoryContextUpdate, setProjectAnnotations, + getPortableStoryWrapperId, inferControls, userOrAutoTitleFromSpecifier, userOrAutoTitle, diff --git a/code/lib/preview-api/src/modules/store/csf/portable-stories.test.ts b/code/lib/preview-api/src/modules/store/csf/portable-stories.test.ts index af775f27360b..54e82553757f 100644 --- a/code/lib/preview-api/src/modules/store/csf/portable-stories.test.ts +++ b/code/lib/preview-api/src/modules/store/csf/portable-stories.test.ts @@ -1,10 +1,18 @@ // @vitest-environment node import { describe, expect, vi, it } from 'vitest'; -import { composeStory, composeStories } from './portable-stories'; +import type { + ComponentAnnotations as Meta, + StoryAnnotationsOrFn as Story, + Store_CSFExports, +} from '@storybook/types'; + +import { composeStory, composeStories, setProjectAnnotations } from './portable-stories'; + +type StoriesModule = Store_CSFExports & Record; // Most integration tests for this functionality are located under renderers/react describe('composeStory', () => { - const meta = { + const meta: Meta = { title: 'Button', parameters: { firstAddon: true, @@ -15,13 +23,26 @@ describe('composeStory', () => { }, }; - it('should return story with composed args and parameters', () => { - const Story = () => {}; - Story.args = { primary: true }; - Story.parameters = { + it('should return story with composed annotations from story, meta and project', () => { + const decoratorFromProjectAnnotations = vi.fn((StoryFn) => StoryFn()); + const decoratorFromStoryAnnotations = vi.fn((StoryFn) => StoryFn()); + setProjectAnnotations([ + { + parameters: { injected: true }, + globalTypes: { + locale: { defaultValue: 'en' }, + }, + decorators: [decoratorFromProjectAnnotations], + }, + ]); + + const Story: Story = { + render: () => {}, + args: { primary: true }, parameters: { secondAddon: true, }, + decorators: [decoratorFromStoryAnnotations], }; const composedStory = composeStory(Story, meta); @@ -29,28 +50,22 @@ describe('composeStory', () => { expect(composedStory.parameters).toEqual( expect.objectContaining({ ...Story.parameters, ...meta.parameters }) ); + + composedStory(); + + expect(decoratorFromProjectAnnotations).toHaveBeenCalled(); + expect(decoratorFromStoryAnnotations).toHaveBeenCalled(); }); it('should compose with a play function', async () => { const spy = vi.fn(); - const Story = () => {}; + const Story: Story = () => {}; Story.args = { primary: true, }; Story.play = async (context: any) => { spy(context); }; - - const composedStory = composeStory(Story, meta); - await composedStory.play!({ canvasElement: null }); - expect(spy).toHaveBeenCalledWith( - expect.objectContaining({ - args: { - ...Story.args, - ...meta.args, - }, - }) - ); }); it('should throw an error if Story is undefined', () => { @@ -62,7 +77,7 @@ describe('composeStory', () => { describe('Id of the story', () => { it('is exposed correctly when composeStories is used', () => { - const module = { + const module: StoriesModule = { default: { title: 'Example/Button', }, @@ -72,7 +87,7 @@ describe('composeStory', () => { expect(Primary.id).toBe('example-button--csf-3-primary'); }); it('is exposed correctly when composeStory is used and exportsName is passed', () => { - const module = { + const module: StoriesModule = { default: { title: 'Example/Button', }, @@ -83,7 +98,7 @@ describe('composeStory', () => { }); it("is not unique when composeStory is used and exportsName isn't passed", () => { const Primary = composeStory({ render: () => {} }, {}); - expect(Primary.id).toContain('unknown'); + expect(Primary.id).toContain('composedstory--unnamed-story'); }); }); }); @@ -93,7 +108,7 @@ describe('composeStories', () => { const defaultAnnotations = { render: () => '' }; it('should call composeStoryFn with stories', () => { const composeStorySpy = vi.fn((v) => v); - const module = { + const module: StoriesModule = { default: { title: 'Button', }, @@ -118,7 +133,7 @@ describe('composeStories', () => { it('should not call composeStoryFn for non-story exports', () => { const composeStorySpy = vi.fn((v) => v); - const module = { + const module: StoriesModule = { default: { title: 'Button', excludeStories: /Data/, @@ -131,7 +146,7 @@ describe('composeStories', () => { describe('non-story exports', () => { it('should filter non-story exports with excludeStories', () => { - const StoryModuleWithNonStoryExports = { + const StoryModuleWithNonStoryExports: StoriesModule = { default: { title: 'Some/Component', excludeStories: /.*Data/, @@ -149,7 +164,7 @@ describe('composeStories', () => { }); it('should filter non-story exports with includeStories', () => { - const StoryModuleWithNonStoryExports = { + const StoryModuleWithNonStoryExports: StoriesModule = { default: { title: 'Some/Component', includeStories: /.*Story/, diff --git a/code/lib/preview-api/src/modules/store/csf/portable-stories.ts b/code/lib/preview-api/src/modules/store/csf/portable-stories.ts index 91e4cdc365e1..6f24b2933cdf 100644 --- a/code/lib/preview-api/src/modules/store/csf/portable-stories.ts +++ b/code/lib/preview-api/src/modules/store/csf/portable-stories.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/naming-convention */ import { isExportStory } from '@storybook/csf'; import type { Renderer, @@ -12,7 +13,7 @@ import type { Parameters, ComposedStoryFn, StrictArgTypes, - ComposedStoryPlayContext, + PlayFunctionContext, } from '@storybook/types'; import { HooksContext } from '../../../addons'; @@ -22,9 +23,14 @@ import { normalizeStory } from './normalizeStory'; import { normalizeComponentAnnotations } from './normalizeComponentAnnotations'; import { getValuesFromArgTypes } from './getValuesFromArgTypes'; import { normalizeProjectAnnotations } from './normalizeProjectAnnotations'; +import { normalizeArrays } from './normalizeArrays'; let GLOBAL_STORYBOOK_PROJECT_ANNOTATIONS = composeConfigs([]); +export function getPortableStoryWrapperId(storyId: string) { + return `storybook-story-${storyId}`; +} + export function setProjectAnnotations( projectAnnotations: ProjectAnnotations | ProjectAnnotations[] ) { @@ -32,6 +38,46 @@ export function setProjectAnnotations( GLOBAL_STORYBOOK_PROJECT_ANNOTATIONS = composeConfigs(annotations); } +// TODO: we don't have any utility that merges two sets of annotations (e.g. two project annotations) +// this should be elsewhere, probably reused +function mergeAnnotations( + firstAnnotations: ProjectAnnotations, + secondAnnotations: ProjectAnnotations +) { + return { + ...firstAnnotations, + ...secondAnnotations, + args: { + ...firstAnnotations.args, + ...secondAnnotations.args, + }, + argTypes: { + ...firstAnnotations.argTypes, + ...secondAnnotations.argTypes, + }, + parameters: { + ...firstAnnotations.parameters, + ...secondAnnotations.parameters, + }, + decorators: [ + ...normalizeArrays(firstAnnotations.decorators), + ...normalizeArrays(secondAnnotations.decorators), + ], + loaders: [ + ...normalizeArrays(firstAnnotations.loaders), + ...normalizeArrays(secondAnnotations.loaders), + ], + argsEnhancers: [ + ...normalizeArrays(firstAnnotations.argsEnhancers), + ...normalizeArrays(secondAnnotations.argsEnhancers), + ], + argTypesEnhancers: [ + ...normalizeArrays(firstAnnotations.argTypesEnhancers), + ...normalizeArrays(secondAnnotations.argTypesEnhancers), + ], + }; +} + export function composeStory( storyAnnotations: LegacyStoryAnnotationsOrFn, componentAnnotations: ComponentAnnotations, @@ -43,6 +89,12 @@ export function composeStory; + } + // @TODO: Support auto title componentAnnotations.title = componentAnnotations.title ?? 'ComposedStory'; @@ -54,7 +106,7 @@ export function composeStory( storyName, @@ -62,10 +114,9 @@ export function composeStory({ - ...projectAnnotations, - ...defaultConfig, - }); + const normalizedProjectAnnotations = normalizeProjectAnnotations( + mergeAnnotations(projectAnnotations, defaultConfig) + ); const story = prepareStory( normalizedStory, @@ -77,7 +128,10 @@ export function composeStory = { hooks: new HooksContext(), - globals: defaultGlobals, + globals: { + ...defaultGlobals, + ...projectAnnotations.globals, + }, args: { ...story.initialArgs }, viewMode: 'story', loaded: {}, @@ -86,28 +140,35 @@ export function composeStory>) => + story.playFunction!({ + ...context, + ...extraContext, + // if canvasElement is not provided, we default to the root element, which comes from a decorator + // the decorator has to be implemented in the defaultAnnotations of each integrator of portable stories + canvasElement: + extraContext?.canvasElement ?? + globalThis.document?.getElementById(getPortableStoryWrapperId(context.id)), + }) + : undefined; + const composedStory: ComposedStoryFn> = Object.assign( - (extraArgs?: Partial) => { - const finalContext: StoryContext = { - ...context, - args: { ...context.initialArgs, ...extraArgs }, + function storyFn(extraArgs?: Partial) { + context.args = { + ...context.initialArgs, + ...extraArgs, }; - return story.unboundStoryFn(prepareContext(finalContext)); + return story.unboundStoryFn(prepareContext(context)); }, { + id: story.id, storyName, args: story.initialArgs as Partial, parameters: story.parameters as Parameters, argTypes: story.argTypes as StrictArgTypes, - id: story.id, - play: story.playFunction - ? ((async (extraContext: ComposedStoryPlayContext) => - story.playFunction!({ - ...context, - ...extraContext, - })) as unknown as ComposedStoryPlayFn>) - : undefined, + play: playFunction as ComposedStoryPlayFn | undefined, } ); @@ -119,7 +180,6 @@ export function composeStories( globalConfig: ProjectAnnotations, composeStoryFn: ComposeStoryFn ) { - // eslint-disable-next-line @typescript-eslint/naming-convention const { default: meta, __esModule, __namedExportsOrder, ...stories } = storiesImport; const composedStories = Object.entries(stories).reduce((storiesMap, [exportsName, story]) => { if (!isExportStory(exportsName, meta)) { diff --git a/code/lib/types/src/modules/composedStory.ts b/code/lib/types/src/modules/composedStory.ts index 5ce61bc678e8..b7051a699b7f 100644 --- a/code/lib/types/src/modules/composedStory.ts +++ b/code/lib/types/src/modules/composedStory.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/naming-convention */ -import type { Renderer, StoryId, StrictArgTypes } from '@storybook/csf'; +import type { PlayFunction, Renderer, StoryId, StrictArgTypes } from '@storybook/csf'; import type { AnnotatedStoryFn, @@ -9,7 +9,6 @@ import type { Parameters, StoryAnnotations, StoryAnnotationsOrFn, - StoryContext, } from './csf'; import type { ProjectAnnotations } from './story'; @@ -22,23 +21,6 @@ export type Store_CSFExports) // or PrimaryButton() - * PrimaryButton.play({ canvasElement: container }) - */ -export type ComposedStoryPlayContext = Partial< - StoryContext & Pick, 'canvasElement'> ->; - -export type ComposedStoryPlayFn = ( - context: ComposedStoryPlayContext -) => Promise | void; - /** * A story function with partial args, used internally by composeStory */ @@ -48,6 +30,14 @@ export type PartialArgsStoryFn = T extends (...args: infer P) => infer R + ? (...args: { [K in keyof P]?: Partial }) => R + : never; + +export type ComposedStoryPlayFn< + TRenderer extends Renderer = Renderer, + TArgs = Args, +> = MakeAllParametersOptional>>; /** * A story that got recomposed for portable stories, containing all the necessary data to be rendered in external environments */ @@ -55,9 +45,9 @@ export type ComposedStoryFn< TRenderer extends Renderer = Renderer, TArgs = Args, > = PartialArgsStoryFn & { - play: ComposedStoryPlayFn | undefined; args: TArgs; id: StoryId; + play?: ComposedStoryPlayFn; storyName: string; parameters: Parameters; argTypes: StrictArgTypes; diff --git a/code/renderers/react/src/__test__/__snapshots__/portable-stories.test.tsx.snap b/code/renderers/react/src/__test__/__snapshots__/portable-stories.test.tsx.snap index 2b92b1d68424..3f40e5446d0b 100644 --- a/code/renderers/react/src/__test__/__snapshots__/portable-stories.test.tsx.snap +++ b/code/renderers/react/src/__test__/__snapshots__/portable-stories.test.tsx.snap @@ -3,12 +3,17 @@ exports[`Renders CSF2Secondary story 1`] = `
- + +
`; @@ -16,12 +21,17 @@ exports[`Renders CSF2Secondary story 1`] = ` exports[`Renders CSF2StoryWithLocale story 1`] = `
- + +
`; @@ -29,12 +39,17 @@ exports[`Renders CSF2StoryWithLocale story 1`] = ` exports[`Renders CSF2StoryWithParamsAndDecorator story 1`] = `
- + +
`; @@ -42,12 +57,17 @@ exports[`Renders CSF2StoryWithParamsAndDecorator story 1`] = ` exports[`Renders CSF3Button story 1`] = `
- + +
`; @@ -55,18 +75,23 @@ exports[`Renders CSF3Button story 1`] = ` exports[`Renders CSF3ButtonWithRender story 1`] = `
-
-

- I am a custom render function -

- +
+
+

+ I am a custom render function +

+ +
@@ -75,9 +100,14 @@ exports[`Renders CSF3ButtonWithRender story 1`] = ` exports[`Renders CSF3InputFieldFilled story 1`] = `
- +
+ +
`; @@ -85,12 +115,17 @@ exports[`Renders CSF3InputFieldFilled story 1`] = ` exports[`Renders CSF3Primary story 1`] = `
- + +
`; diff --git a/code/renderers/react/src/__test__/portable-stories.test.tsx b/code/renderers/react/src/__test__/portable-stories.test.tsx index afa0b70142e4..f137424fff0b 100644 --- a/code/renderers/react/src/__test__/portable-stories.test.tsx +++ b/code/renderers/react/src/__test__/portable-stories.test.tsx @@ -58,7 +58,7 @@ describe('projectAnnotations', () => { expect(buttonElement).not.toBeNull(); }); - it('renders with custom projectAnnotations via composeStory params', () => { + it('renders with custom globals from projectAnnotations via composeStory params', () => { const WithPortugueseText = composeStory(stories.CSF2StoryWithLocale, stories.default, { globalTypes: { locale: { defaultValue: 'pt' } } as any, }); @@ -94,7 +94,18 @@ describe('CSF3', () => { expect(screen.getByTestId('custom-render')).not.toBeNull(); }); - it('renders with play function', async () => { + it('renders with play function without canvas element', async () => { + const CSF3InputFieldFilled = composeStory(stories.CSF3InputFieldFilled, stories.default); + + render(); + + await CSF3InputFieldFilled.play!(); + + const input = screen.getByTestId('input') as HTMLInputElement; + expect(input.value).toEqual('Hello world!'); + }); + + it('renders with play function with canvas element', async () => { const CSF3InputFieldFilled = composeStory(stories.CSF3InputFieldFilled, stories.default); const { container } = render(); diff --git a/code/renderers/react/src/portable-stories.ts b/code/renderers/react/src/portable-stories.tsx similarity index 94% rename from code/renderers/react/src/portable-stories.ts rename to code/renderers/react/src/portable-stories.tsx index 385e0dc4c804..55ca55c69ff4 100644 --- a/code/renderers/react/src/portable-stories.ts +++ b/code/renderers/react/src/portable-stories.tsx @@ -1,7 +1,9 @@ +import React from 'react'; import { composeStory as originalComposeStory, composeStories as originalComposeStories, setProjectAnnotations as originalSetProjectAnnotations, + getPortableStoryWrapperId, } from '@storybook/preview-api'; import type { Args, @@ -39,6 +41,15 @@ export function setProjectAnnotations( // This will not be necessary once we have auto preset loading const defaultProjectAnnotations: ProjectAnnotations = { render, + decorators: [ + function addStorybookId(StoryFn, { id }) { + return ( +
+ {StoryFn()} +
+ ); + }, + ], }; /** diff --git a/code/renderers/vue3/src/__tests__/composeStories/__snapshots__/portable-stories.test.ts.snap b/code/renderers/vue3/src/__tests__/composeStories/__snapshots__/portable-stories.test.ts.snap index 75eab08758cf..aca5cb96961b 100644 --- a/code/renderers/vue3/src/__tests__/composeStories/__snapshots__/portable-stories.test.ts.snap +++ b/code/renderers/vue3/src/__tests__/composeStories/__snapshots__/portable-stories.test.ts.snap @@ -3,56 +3,51 @@ exports[`Renders CSF2Secondary story 1`] = `
- -
- -`; - -exports[`Renders CSF2StoryWithLocale story 1`] = ` - -
-
-

- locale: undefined -

`; -exports[`Renders CSF3Button story 1`] = ` +exports[`Renders CSF2StoryWithLocale story 1`] = `
- +
+

+ locale: undefined +

+ +
+
`; -exports[`Renders CSF3ButtonWithRender story 1`] = ` +exports[`Renders CSF3Button story 1`] = `
-
-

- I am a custom render function -

+
+
+
+
+ +`; + exports[`Renders CSF3InputFieldFilled story 1`] = `
- +
+ +
`; @@ -77,12 +102,17 @@ exports[`Renders CSF3InputFieldFilled story 1`] = ` exports[`Renders CSF3Primary story 1`] = `
- + +
`; diff --git a/code/renderers/vue3/src/__tests__/composeStories/portable-stories.test.ts b/code/renderers/vue3/src/__tests__/composeStories/portable-stories.test.ts index 4c541e1c4536..97fb295c47bc 100644 --- a/code/renderers/vue3/src/__tests__/composeStories/portable-stories.test.ts +++ b/code/renderers/vue3/src/__tests__/composeStories/portable-stories.test.ts @@ -84,9 +84,9 @@ describe('CSF3', () => { it('renders with play function', async () => { const CSF3InputFieldFilled = composeStory(stories.CSF3InputFieldFilled, stories.default); - const { container } = render(CSF3InputFieldFilled()); + render(CSF3InputFieldFilled()); - await CSF3InputFieldFilled.play!({ canvasElement: container as HTMLElement }); + await CSF3InputFieldFilled.play!(); const input = screen.getByTestId('input') as HTMLInputElement; expect(input.value).toEqual('Hello world!'); diff --git a/code/renderers/vue3/src/portable-stories.ts b/code/renderers/vue3/src/portable-stories.ts index 4e009b25d672..aef26b39a5e7 100644 --- a/code/renderers/vue3/src/portable-stories.ts +++ b/code/renderers/vue3/src/portable-stories.ts @@ -2,6 +2,7 @@ import { composeStory as originalComposeStory, composeStories as originalComposeStories, setProjectAnnotations as originalSetProjectAnnotations, + getPortableStoryWrapperId, } from '@storybook/preview-api'; import type { Args, @@ -11,10 +12,24 @@ import type { StoriesWithPartialProps, } from '@storybook/types'; -import * as defaultProjectAnnotations from './render'; +import * as vueProjectAnnotations from './entry-preview'; import type { Meta } from './public-types'; import type { VueRenderer } from './types'; +const defaultProjectAnnotations: ProjectAnnotations = { + ...vueProjectAnnotations, + decorators: [ + function addStorybookId(story, { id }) { + return { + components: { story }, + template: `
+ +
`, + }; + }, + ], +}; + /** 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`. From 0694772841f956bcf55ec81f8aba71771870f71e Mon Sep 17 00:00:00 2001 From: Yann Braga Date: Fri, 1 Mar 2024 12:23:40 +0100 Subject: [PATCH 02/11] Update code/lib/preview-api/src/modules/store/csf/portable-stories.test.ts Co-authored-by: Jeppe Reinhold --- .../src/modules/store/csf/portable-stories.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/code/lib/preview-api/src/modules/store/csf/portable-stories.test.ts b/code/lib/preview-api/src/modules/store/csf/portable-stories.test.ts index 54e82553757f..9d3602ecc492 100644 --- a/code/lib/preview-api/src/modules/store/csf/portable-stories.test.ts +++ b/code/lib/preview-api/src/modules/store/csf/portable-stories.test.ts @@ -53,8 +53,8 @@ describe('composeStory', () => { composedStory(); - expect(decoratorFromProjectAnnotations).toHaveBeenCalled(); - expect(decoratorFromStoryAnnotations).toHaveBeenCalled(); + expect(decoratorFromProjectAnnotations).toHaveBeenCalledOnce(); + expect(decoratorFromStoryAnnotations).toHaveBeenCalledOnce(); }); it('should compose with a play function', async () => { From d39b6c93b433750ebdf33f68c855923872d41954 Mon Sep 17 00:00:00 2001 From: Yann Braga Date: Fri, 1 Mar 2024 14:03:12 +0100 Subject: [PATCH 03/11] use composeConfigs function --- .../src/modules/store/csf/portable-stories.ts | 43 +------------------ 1 file changed, 1 insertion(+), 42 deletions(-) diff --git a/code/lib/preview-api/src/modules/store/csf/portable-stories.ts b/code/lib/preview-api/src/modules/store/csf/portable-stories.ts index 6f24b2933cdf..fbe3898fc3d2 100644 --- a/code/lib/preview-api/src/modules/store/csf/portable-stories.ts +++ b/code/lib/preview-api/src/modules/store/csf/portable-stories.ts @@ -23,7 +23,6 @@ import { normalizeStory } from './normalizeStory'; import { normalizeComponentAnnotations } from './normalizeComponentAnnotations'; import { getValuesFromArgTypes } from './getValuesFromArgTypes'; import { normalizeProjectAnnotations } from './normalizeProjectAnnotations'; -import { normalizeArrays } from './normalizeArrays'; let GLOBAL_STORYBOOK_PROJECT_ANNOTATIONS = composeConfigs([]); @@ -38,46 +37,6 @@ export function setProjectAnnotations( GLOBAL_STORYBOOK_PROJECT_ANNOTATIONS = composeConfigs(annotations); } -// TODO: we don't have any utility that merges two sets of annotations (e.g. two project annotations) -// this should be elsewhere, probably reused -function mergeAnnotations( - firstAnnotations: ProjectAnnotations, - secondAnnotations: ProjectAnnotations -) { - return { - ...firstAnnotations, - ...secondAnnotations, - args: { - ...firstAnnotations.args, - ...secondAnnotations.args, - }, - argTypes: { - ...firstAnnotations.argTypes, - ...secondAnnotations.argTypes, - }, - parameters: { - ...firstAnnotations.parameters, - ...secondAnnotations.parameters, - }, - decorators: [ - ...normalizeArrays(firstAnnotations.decorators), - ...normalizeArrays(secondAnnotations.decorators), - ], - loaders: [ - ...normalizeArrays(firstAnnotations.loaders), - ...normalizeArrays(secondAnnotations.loaders), - ], - argsEnhancers: [ - ...normalizeArrays(firstAnnotations.argsEnhancers), - ...normalizeArrays(secondAnnotations.argsEnhancers), - ], - argTypesEnhancers: [ - ...normalizeArrays(firstAnnotations.argTypesEnhancers), - ...normalizeArrays(secondAnnotations.argTypesEnhancers), - ], - }; -} - export function composeStory( storyAnnotations: LegacyStoryAnnotationsOrFn, componentAnnotations: ComponentAnnotations, @@ -115,7 +74,7 @@ export function composeStory( - mergeAnnotations(projectAnnotations, defaultConfig) + composeConfigs([projectAnnotations, defaultConfig]) ); const story = prepareStory( From a2891859107a10b48f5392de7bd0bd30aa7a4cfe Mon Sep 17 00:00:00 2001 From: Yann Braga Date: Fri, 1 Mar 2024 14:09:13 +0100 Subject: [PATCH 04/11] update migration notes --- MIGRATION.md | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index c51ddba6347e..62aef1593d05 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -3,7 +3,7 @@ - [From version 7.x to 8.0.0](#from-version-7x-to-800) - [Portable stories](#portable-stories) - [Type change in `composeStories` API](#type-change-in-composestories-api) - - [The context in the play function is now optional](#the-context-in-the-play-function-is-now-optional) + - [DOM structure changed in portable stories](#dom-structure-changed-in-portable-stories) - [Tab addons are now routed to a query parameter](#tab-addons-are-now-routed-to-a-query-parameter) - [Default keyboard shortcuts changed](#default-keyboard-shortcuts-changed) - [Manager addons are now rendered with React 18](#manager-addons-are-now-rendered-with-react-18) @@ -422,23 +422,9 @@ await Primary.play!(...) // if you want a runtime error when the play function d There are plans to make the type of the play function be inferred based on your imported story's play function in a near future, so the types will be 100% accurate. -#### The context in the play function is now optional +#### DOM structure changed in portable stories -When reusing a story that has a play function, you don't have to pass the context anymore, not even the `canvasElement`. The context is built-in and if you don't pass overrides, it will still be present. It is still possible to pass overrides to the context, if you'd like. - -```tsx -const { Primary } = composeStories(stories); -test("load and render", async () => { - const { container } = render(); - // before: - await Primary.play({ canvasElement: container, ...ArgsOrWhateverElse }); - - // after: - await Primary.play(); -}); -``` - -In order for this to be possible, the portable stories API now adds a wrapper to your stories with a unique id based on your story id, such as: +The portable stories API now adds a wrapper to your stories with a unique id based on your story id, such as: ```html
From f60fee3fea81fd40888b9c7f494377e56d1cdbf0 Mon Sep 17 00:00:00 2001 From: Yann Braga Date: Fri, 1 Mar 2024 16:35:54 +0100 Subject: [PATCH 05/11] fix compose configs order --- code/lib/preview-api/src/modules/store/csf/portable-stories.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/lib/preview-api/src/modules/store/csf/portable-stories.ts b/code/lib/preview-api/src/modules/store/csf/portable-stories.ts index fbe3898fc3d2..c3dfd53d2877 100644 --- a/code/lib/preview-api/src/modules/store/csf/portable-stories.ts +++ b/code/lib/preview-api/src/modules/store/csf/portable-stories.ts @@ -74,7 +74,7 @@ export function composeStory( - composeConfigs([projectAnnotations, defaultConfig]) + composeConfigs([defaultConfig, projectAnnotations]) ); const story = prepareStory( From 2552e0395840e37f2c988434a497de231b756ded Mon Sep 17 00:00:00 2001 From: Yann Braga Date: Fri, 1 Mar 2024 16:39:28 +0100 Subject: [PATCH 06/11] bring test assertions back --- .../src/modules/store/csf/portable-stories.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/code/lib/preview-api/src/modules/store/csf/portable-stories.test.ts b/code/lib/preview-api/src/modules/store/csf/portable-stories.test.ts index 9d3602ecc492..6a6d059834de 100644 --- a/code/lib/preview-api/src/modules/store/csf/portable-stories.test.ts +++ b/code/lib/preview-api/src/modules/store/csf/portable-stories.test.ts @@ -66,6 +66,17 @@ describe('composeStory', () => { Story.play = async (context: any) => { spy(context); }; + + const composedStory = composeStory(Story, meta); + await composedStory.play!({ canvasElement: null }); + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + args: { + ...Story.args, + ...meta.args, + }, + }) + ); }); it('should throw an error if Story is undefined', () => { From 40b56a46b42b834c771674d7a3d1140d2f4c4d51 Mon Sep 17 00:00:00 2001 From: Yann Braga Date: Fri, 1 Mar 2024 16:40:11 +0100 Subject: [PATCH 07/11] use jsx in portable stories react decorator --- code/renderers/react/src/portable-stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/renderers/react/src/portable-stories.tsx b/code/renderers/react/src/portable-stories.tsx index 55ca55c69ff4..bf91dde7b3e8 100644 --- a/code/renderers/react/src/portable-stories.tsx +++ b/code/renderers/react/src/portable-stories.tsx @@ -45,7 +45,7 @@ const defaultProjectAnnotations: ProjectAnnotations = { function addStorybookId(StoryFn, { id }) { return (
- {StoryFn()} +
); }, From 1dd146fbd045f853d0312e4aa5daab5b8d96daa3 Mon Sep 17 00:00:00 2001 From: Yann Braga Date: Fri, 1 Mar 2024 16:43:08 +0100 Subject: [PATCH 08/11] use play function on snapshot tests --- .../react/src/__test__/portable-stories.test.tsx | 16 ++++++++++++---- .../composeStories/portable-stories.test.ts | 6 ++++-- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/code/renderers/react/src/__test__/portable-stories.test.tsx b/code/renderers/react/src/__test__/portable-stories.test.tsx index f137424fff0b..3c7edd4e8e17 100644 --- a/code/renderers/react/src/__test__/portable-stories.test.tsx +++ b/code/renderers/react/src/__test__/portable-stories.test.tsx @@ -1,5 +1,5 @@ -import { vi, it, expect, afterEach, describe } from 'vitest'; import React from 'react'; +import { vi, it, expect, afterEach, describe } from 'vitest'; import { render, screen, cleanup } from '@testing-library/react'; import { addons } from '@storybook/preview-api'; import type { Meta } from '@storybook/react'; @@ -150,9 +150,17 @@ describe('ComposeStories types', () => { }); // Batch snapshot testing -const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName, Story]); +const testCases = Object.values(composeStories(stories)).map( + (Story) => [Story.storyName, Story] as [string, typeof Story] +); it.each(testCases)('Renders %s story', async (_storyName, Story) => { cleanup(); - const tree = await render(); - expect(tree.baseElement).toMatchSnapshot(); + + if (_storyName === 'CSF2WithLocale') { + return; + } + + const { baseElement } = await render(); + await Story.play?.(); + expect(baseElement).toMatchSnapshot(); }); diff --git a/code/renderers/vue3/src/__tests__/composeStories/portable-stories.test.ts b/code/renderers/vue3/src/__tests__/composeStories/portable-stories.test.ts index 97fb295c47bc..e82a6c657a89 100644 --- a/code/renderers/vue3/src/__tests__/composeStories/portable-stories.test.ts +++ b/code/renderers/vue3/src/__tests__/composeStories/portable-stories.test.ts @@ -133,6 +133,8 @@ it.each(testCases)('Renders %s story', async (_storyName, Story) => { await new Promise((resolve) => setTimeout(resolve, 0)); - const tree = await render(Story()); - expect(tree.baseElement).toMatchSnapshot(); + const { baseElement } = await render(Story()); + await Story.play?.(); + + expect(baseElement).toMatchSnapshot(); }); From 69e69d182a754c0ec4d3d326588d9c07b1cfb285 Mon Sep 17 00:00:00 2001 From: Yann Braga Date: Mon, 4 Mar 2024 10:28:54 +0100 Subject: [PATCH 09/11] fix proejct annotation merging logic --- .../store/csf/portable-stories.test.ts | 25 +++++++++++++++++++ .../src/modules/store/csf/portable-stories.ts | 22 ++++++---------- 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/code/lib/preview-api/src/modules/store/csf/portable-stories.test.ts b/code/lib/preview-api/src/modules/store/csf/portable-stories.test.ts index 6a6d059834de..3f2876be4c2c 100644 --- a/code/lib/preview-api/src/modules/store/csf/portable-stories.test.ts +++ b/code/lib/preview-api/src/modules/store/csf/portable-stories.test.ts @@ -79,6 +79,31 @@ describe('composeStory', () => { ); }); + it('should merge parameters with correct precedence in all combinations', async () => { + const storyAnnotations = { render: () => {} }; + const metaAnnotations: Meta = { parameters: { label: 'meta' } }; + const projectAnnotations: Meta = { parameters: { label: 'projectOverrides' } }; + + const storyPrecedence = composeStory( + { ...storyAnnotations, parameters: { label: 'story' } }, + metaAnnotations, + projectAnnotations + ); + expect(storyPrecedence.parameters.label).toEqual('story'); + + const metaPrecedence = composeStory(storyAnnotations, metaAnnotations, projectAnnotations); + expect(metaPrecedence.parameters.label).toEqual('meta'); + + const projectPrecedence = composeStory(storyAnnotations, {}, projectAnnotations); + expect(projectPrecedence.parameters.label).toEqual('projectOverrides'); + + setProjectAnnotations({ parameters: { label: 'setProjectAnnotationsOverrides' } }); + const setProjectAnnotationsPrecedence = composeStory(storyAnnotations, {}, {}); + expect(setProjectAnnotationsPrecedence.parameters.label).toEqual( + 'setProjectAnnotationsOverrides' + ); + }); + it('should throw an error if Story is undefined', () => { expect(() => { // @ts-expect-error (invalid input) diff --git a/code/lib/preview-api/src/modules/store/csf/portable-stories.ts b/code/lib/preview-api/src/modules/store/csf/portable-stories.ts index c3dfd53d2877..65d38e1b9805 100644 --- a/code/lib/preview-api/src/modules/store/csf/portable-stories.ts +++ b/code/lib/preview-api/src/modules/store/csf/portable-stories.ts @@ -24,7 +24,7 @@ import { normalizeComponentAnnotations } from './normalizeComponentAnnotations'; import { getValuesFromArgTypes } from './getValuesFromArgTypes'; import { normalizeProjectAnnotations } from './normalizeProjectAnnotations'; -let GLOBAL_STORYBOOK_PROJECT_ANNOTATIONS = composeConfigs([]); +let globalProjectAnnotations: ProjectAnnotations = {}; export function getPortableStoryWrapperId(storyId: string) { return `storybook-story-${storyId}`; @@ -34,26 +34,20 @@ export function setProjectAnnotations( projectAnnotations: ProjectAnnotations | ProjectAnnotations[] ) { const annotations = Array.isArray(projectAnnotations) ? projectAnnotations : [projectAnnotations]; - GLOBAL_STORYBOOK_PROJECT_ANNOTATIONS = composeConfigs(annotations); + globalProjectAnnotations = composeConfigs(annotations); } export function composeStory( storyAnnotations: LegacyStoryAnnotationsOrFn, componentAnnotations: ComponentAnnotations, - projectAnnotations: ProjectAnnotations = GLOBAL_STORYBOOK_PROJECT_ANNOTATIONS as ProjectAnnotations, - defaultConfig: ProjectAnnotations = {}, + projectAnnotations?: ProjectAnnotations, + defaultConfig?: ProjectAnnotations, exportsName?: string ): ComposedStoryFn> { if (storyAnnotations === undefined) { throw new Error('Expected a story but received undefined.'); } - // users might pass an empty object instead of undefined e.g. composeStory(story, meta, {}, exportsName) - // and likely they expect the default project annotations to be used instead of completely resetting them - if (typeof projectAnnotations === 'object' && Object.keys(projectAnnotations).length === 0) { - projectAnnotations = GLOBAL_STORYBOOK_PROJECT_ANNOTATIONS as ProjectAnnotations; - } - // @TODO: Support auto title componentAnnotations.title = componentAnnotations.title ?? 'ComposedStory'; @@ -74,7 +68,7 @@ export function composeStory( - composeConfigs([defaultConfig, projectAnnotations]) + composeConfigs([defaultConfig ?? {}, globalProjectAnnotations, projectAnnotations ?? {}]) ); const story = prepareStory( @@ -83,13 +77,13 @@ export function composeStory = { hooks: new HooksContext(), globals: { - ...defaultGlobals, - ...projectAnnotations.globals, + ...globalsFromGlobalTypes, + ...normalizedProjectAnnotations.globals, }, args: { ...story.initialArgs }, viewMode: 'story', From 2b6ce98d791aa6f06082d6ed185ff40826bb1674 Mon Sep 17 00:00:00 2001 From: Yann Braga Date: Mon, 4 Mar 2024 11:47:58 +0100 Subject: [PATCH 10/11] Portable stories: Add support for loaders --- .../store/csf/portable-stories.test.ts | 49 ++++++++++++ .../src/modules/store/csf/portable-stories.ts | 5 ++ code/lib/types/src/modules/composedStory.ts | 1 + .../react/src/__test__/Button.stories.tsx | 44 ++++++++++- .../portable-stories.test.tsx.snap | 42 +++++----- .../src/__test__/portable-stories.test.tsx | 29 ++++++- code/renderers/react/src/portable-stories.tsx | 4 +- .../composeStories/Button.stories.ts | 42 +++++++++- .../portable-stories.test.ts.snap | 37 +++++++-- .../composeStories/portable-stories.test.ts | 77 ++++++++++++------- 10 files changed, 262 insertions(+), 68 deletions(-) diff --git a/code/lib/preview-api/src/modules/store/csf/portable-stories.test.ts b/code/lib/preview-api/src/modules/store/csf/portable-stories.test.ts index 3f2876be4c2c..dbca4640b05b 100644 --- a/code/lib/preview-api/src/modules/store/csf/portable-stories.test.ts +++ b/code/lib/preview-api/src/modules/store/csf/portable-stories.test.ts @@ -104,6 +104,55 @@ describe('composeStory', () => { ); }); + it('should call and compose loaders data', async () => { + const loadSpy = vi.fn(); + const args = { story: 'story' }; + const LoaderStory: Story = { + args, + loaders: [ + async (context) => { + loadSpy(); + expect(context.args).toEqual(args); + return { + foo: 'bar', + }; + }, + ], + render: (_args, { loaded }) => { + expect(loaded).toEqual({ foo: 'bar' }); + }, + }; + + const composedStory = composeStory(LoaderStory, {}); + await composedStory.load(); + composedStory(); + expect(loadSpy).toHaveBeenCalled(); + }); + + it('should work with spies set up in loaders', async () => { + const spyFn = vi.fn(); + + const Story: Story = { + args: { + spyFn, + }, + loaders: [ + async () => { + spyFn.mockReturnValue('mockedData'); + }, + ], + render: (args) => { + const data = args.spyFn(); + expect(data).toBe('mockedData'); + }, + }; + + const composedStory = composeStory(Story, {}); + await composedStory.load(); + composedStory(); + expect(spyFn).toHaveBeenCalled(); + }); + it('should throw an error if Story is undefined', () => { expect(() => { // @ts-expect-error (invalid input) diff --git a/code/lib/preview-api/src/modules/store/csf/portable-stories.ts b/code/lib/preview-api/src/modules/store/csf/portable-stories.ts index 65d38e1b9805..0974f0908526 100644 --- a/code/lib/preview-api/src/modules/store/csf/portable-stories.ts +++ b/code/lib/preview-api/src/modules/store/csf/portable-stories.ts @@ -45,6 +45,7 @@ export function composeStory> { if (storyAnnotations === undefined) { + // eslint-disable-next-line local-rules/no-uncategorized-errors throw new Error('Expected a story but received undefined.'); } @@ -118,6 +119,10 @@ export function composeStory { + const loadedContext = await story.applyLoaders(context); + context.loaded = loadedContext.loaded; + }, args: story.initialArgs as Partial, parameters: story.parameters as Parameters, argTypes: story.argTypes as StrictArgTypes, diff --git a/code/lib/types/src/modules/composedStory.ts b/code/lib/types/src/modules/composedStory.ts index b7051a699b7f..b0a7bff6c374 100644 --- a/code/lib/types/src/modules/composedStory.ts +++ b/code/lib/types/src/modules/composedStory.ts @@ -48,6 +48,7 @@ export type ComposedStoryFn< args: TArgs; id: StoryId; play?: ComposedStoryPlayFn; + load: () => Promise; storyName: string; parameters: Parameters; argTypes: StrictArgTypes; diff --git a/code/renderers/react/src/__test__/Button.stories.tsx b/code/renderers/react/src/__test__/Button.stories.tsx index 6882b957b136..277f92ddde1f 100644 --- a/code/renderers/react/src/__test__/Button.stories.tsx +++ b/code/renderers/react/src/__test__/Button.stories.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { within, userEvent } from '@storybook/testing-library'; +import { within, userEvent, fn, expect } from '@storybook/test'; import type { StoryFn as CSF2Story, StoryObj as CSF3Story, Meta } from '..'; import type { ButtonProps } from './Button'; @@ -33,14 +33,21 @@ const getCaptionForLocale = (locale: string) => { return '안녕하세요!'; case 'pt': return 'Olá!'; - default: + case 'en': return 'Hello!'; + default: + return undefined; } }; export const CSF2StoryWithLocale: CSF2Story = (args, { globals: { locale } }) => { const caption = getCaptionForLocale(locale); - return ; + return ( + <> +

locale: {locale}

+ + + ); }; CSF2StoryWithLocale.storyName = 'WithLocale'; @@ -84,7 +91,36 @@ export const CSF3InputFieldFilled: CSF3Story = { play: async ({ canvasElement, step }) => { const canvas = within(canvasElement); await step('Step label', async () => { - await userEvent.type(canvas.getByTestId('input'), 'Hello world!'); + const inputEl = canvas.getByTestId('input'); + await userEvent.type(inputEl, 'Hello world!'); + await expect(inputEl).toHaveValue('Hello world!'); }); }, }; + +const mockFn = fn(); +export const LoaderStory: CSF3Story<{ mockFn: (val: string) => string }> = { + args: { + mockFn, + }, + loaders: [ + async () => { + mockFn.mockReturnValueOnce('mockFn return value'); + return { + value: 'loaded data', + }; + }, + ], + render: (args, { loaded }) => { + const data = args.mockFn('render'); + return ( +
+
{loaded.value}
+
{String(data)}
+
+ ); + }, + play: async () => { + expect(mockFn).toHaveBeenCalledWith('render'); + }, +}; diff --git a/code/renderers/react/src/__test__/__snapshots__/portable-stories.test.tsx.snap b/code/renderers/react/src/__test__/__snapshots__/portable-stories.test.tsx.snap index 3f40e5446d0b..2779b21001ab 100644 --- a/code/renderers/react/src/__test__/__snapshots__/portable-stories.test.tsx.snap +++ b/code/renderers/react/src/__test__/__snapshots__/portable-stories.test.tsx.snap @@ -18,24 +18,6 @@ exports[`Renders CSF2Secondary story 1`] = ` `; -exports[`Renders CSF2StoryWithLocale story 1`] = ` - -
-
- -
-
- -`; - exports[`Renders CSF2StoryWithParamsAndDecorator story 1`] = `
@@ -129,3 +111,27 @@ exports[`Renders CSF3Primary story 1`] = `
`; + +exports[`Renders LoaderStory story 1`] = ` + +
+
+
+
+ loaded data +
+
+ mockFn return value +
+
+
+
+ +`; diff --git a/code/renderers/react/src/__test__/portable-stories.test.tsx b/code/renderers/react/src/__test__/portable-stories.test.tsx index 3c7edd4e8e17..f8aae6b849f4 100644 --- a/code/renderers/react/src/__test__/portable-stories.test.tsx +++ b/code/renderers/react/src/__test__/portable-stories.test.tsx @@ -10,7 +10,7 @@ import type { Button } from './Button'; import * as stories from './Button.stories'; // example with composeStories, returns an object with all stories composed with args/decorators -const { CSF3Primary } = composeStories(stories); +const { CSF3Primary, LoaderStory } = composeStories(stories); // example with composeStory, returns a single story composed with args/decorators const Secondary = composeStory(stories.CSF2Secondary, stories.default); @@ -44,6 +44,15 @@ describe('renders', () => { const buttonElement = getByText(/foo/i); expect(buttonElement).not.toBeNull(); }); + + it('should call and compose loaders data', async () => { + await LoaderStory.load(); + const { getByTestId } = render(); + expect(getByTestId('spy-data').textContent).toEqual('mockFn return value'); + expect(getByTestId('loaded-data').textContent).toEqual('loaded data'); + // spy assertions happen in the play function and should work + await LoaderStory.play!(); + }); }); describe('projectAnnotations', () => { @@ -52,15 +61,24 @@ describe('projectAnnotations', () => { }); it('renders with default projectAnnotations', () => { + setProjectAnnotations([ + { + parameters: { injected: true }, + globalTypes: { + locale: { defaultValue: 'en' }, + }, + }, + ]); const WithEnglishText = composeStory(stories.CSF2StoryWithLocale, stories.default); const { getByText } = render(); const buttonElement = getByText('Hello!'); expect(buttonElement).not.toBeNull(); + expect(WithEnglishText.parameters?.injected).toBe(true); }); - it('renders with custom globals from projectAnnotations via composeStory params', () => { + it('renders with custom projectAnnotations via composeStory params', () => { const WithPortugueseText = composeStory(stories.CSF2StoryWithLocale, stories.default, { - globalTypes: { locale: { defaultValue: 'pt' } } as any, + globals: { locale: 'pt' }, }); const { getByText } = render(); const buttonElement = getByText('Olá!'); @@ -156,11 +174,14 @@ const testCases = Object.values(composeStories(stories)).map( it.each(testCases)('Renders %s story', async (_storyName, Story) => { cleanup(); - if (_storyName === 'CSF2WithLocale') { + if (_storyName === 'CSF2StoryWithLocale') { return; } + await Story.load(); + const { baseElement } = await render(); + await Story.play?.(); expect(baseElement).toMatchSnapshot(); }); diff --git a/code/renderers/react/src/portable-stories.tsx b/code/renderers/react/src/portable-stories.tsx index bf91dde7b3e8..3493e0f3b2e5 100644 --- a/code/renderers/react/src/portable-stories.tsx +++ b/code/renderers/react/src/portable-stories.tsx @@ -13,7 +13,7 @@ import type { StoriesWithPartialProps, } from '@storybook/types'; -import { render } from './render'; +import * as reactProjectAnnotations from './entry-preview'; import type { Meta } from './public-types'; import type { ReactRenderer } from './types'; @@ -40,7 +40,7 @@ export function setProjectAnnotations( // This will not be necessary once we have auto preset loading const defaultProjectAnnotations: ProjectAnnotations = { - render, + ...reactProjectAnnotations, decorators: [ function addStorybookId(StoryFn, { id }) { return ( diff --git a/code/renderers/vue3/src/__tests__/composeStories/Button.stories.ts b/code/renderers/vue3/src/__tests__/composeStories/Button.stories.ts index 239416df5c35..0d4623585de0 100644 --- a/code/renderers/vue3/src/__tests__/composeStories/Button.stories.ts +++ b/code/renderers/vue3/src/__tests__/composeStories/Button.stories.ts @@ -1,4 +1,4 @@ -import { userEvent, within } from '@storybook/testing-library'; +import { userEvent, within, expect, fn } from '@storybook/test'; import type { Meta, StoryFn as CSF2Story, StoryObj } from '../..'; import Button from './Button.vue'; @@ -45,8 +45,10 @@ const getCaptionForLocale = (locale: string) => { return '안녕하세요!'; case 'pt': return 'Olá!'; - default: + case 'en': return 'Hello!'; + default: + return undefined; } }; @@ -58,7 +60,7 @@ export const CSF2StoryWithLocale: CSF2Story = (args, { globals }) => ({ }, template: `

locale: ${globals.locale}

-
`, }); CSF2StoryWithLocale.storyName = 'WithLocale'; @@ -114,7 +116,39 @@ export const CSF3InputFieldFilled: CSF3Story = { play: async ({ canvasElement, step }) => { const canvas = within(canvasElement); await step('Step label', async () => { - await userEvent.type(canvas.getByTestId('input'), 'Hello world!'); + const inputEl = canvas.getByTestId('input'); + await userEvent.type(inputEl, 'Hello world!'); + await expect(inputEl).toHaveValue('Hello world!'); }); }, }; + +const mockFn = fn(); +export const LoaderStory: StoryObj<{ mockFn: (val: string) => string }> = { + args: { + mockFn, + }, + loaders: [ + async () => { + mockFn.mockReturnValueOnce('mockFn return value'); + return { + value: 'loaded data', + }; + }, + ], + render: (args, { loaded }) => ({ + components: { Button }, + setup() { + return { args, data: args.mockFn('render'), loaded: loaded.value }; + }, + template: ` +
+
{{loaded}}
+
{{data}}
+
+ `, + }), + play: async () => { + expect(mockFn).toHaveBeenCalledWith('render'); + }, +}; diff --git a/code/renderers/vue3/src/__tests__/composeStories/__snapshots__/portable-stories.test.ts.snap b/code/renderers/vue3/src/__tests__/composeStories/__snapshots__/portable-stories.test.ts.snap index aca5cb96961b..b6e6feff8f61 100644 --- a/code/renderers/vue3/src/__tests__/composeStories/__snapshots__/portable-stories.test.ts.snap +++ b/code/renderers/vue3/src/__tests__/composeStories/__snapshots__/portable-stories.test.ts.snap @@ -18,22 +18,21 @@ exports[`Renders CSF2Secondary story 1`] = ` `; -exports[`Renders CSF2StoryWithLocale story 1`] = ` +exports[`Renders CSF2StoryWithParamsAndDecorator story 1`] = `
-
-

- locale: undefined -

+
@@ -116,3 +115,27 @@ exports[`Renders CSF3Primary story 1`] = `
`; + +exports[`Renders LoaderStory story 1`] = ` + +
+
+
+
+ loaded data +
+
+ mockFn return value +
+
+
+
+ +`; diff --git a/code/renderers/vue3/src/__tests__/composeStories/portable-stories.test.ts b/code/renderers/vue3/src/__tests__/composeStories/portable-stories.test.ts index e82a6c657a89..36aa7c2e9c56 100644 --- a/code/renderers/vue3/src/__tests__/composeStories/portable-stories.test.ts +++ b/code/renderers/vue3/src/__tests__/composeStories/portable-stories.test.ts @@ -10,39 +10,58 @@ import type Button from './Button.vue'; import { composeStories, composeStory, setProjectAnnotations } from '../../portable-stories'; // example with composeStories, returns an object with all stories composed with args/decorators -const { CSF3Primary } = composeStories(stories); +const { CSF3Primary, LoaderStory } = composeStories(stories); // example with composeStory, returns a single story composed with args/decorators const Secondary = composeStory(stories.CSF2Secondary, stories.default); -it('renders primary button', () => { - render(CSF3Primary({ label: 'Hello world' })); - const buttonElement = screen.getByText(/Hello world/i); - expect(buttonElement).toBeInTheDocument(); -}); +describe('renders', () => { + it('renders primary button', () => { + render(CSF3Primary({ label: 'Hello world' })); + const buttonElement = screen.getByText(/Hello world/i); + expect(buttonElement).toBeInTheDocument(); + }); -it('reuses args from composed story', () => { - render(Secondary()); - const buttonElement = screen.getByRole('button'); - expect(buttonElement.textContent).toEqual(Secondary.args.label); -}); + it('reuses args from composed story', () => { + render(Secondary()); + const buttonElement = screen.getByRole('button'); + expect(buttonElement.textContent).toEqual(Secondary.args.label); + }); -it('myClickEvent handler is called', async () => { - const myClickEventSpy = vi.fn(); - render(Secondary({ onMyClickEvent: myClickEventSpy })); - const buttonElement = screen.getByRole('button'); - buttonElement.click(); - expect(myClickEventSpy).toHaveBeenCalled(); -}); + it('myClickEvent handler is called', async () => { + const myClickEventSpy = vi.fn(); + render(Secondary({ onMyClickEvent: myClickEventSpy })); + const buttonElement = screen.getByRole('button'); + buttonElement.click(); + expect(myClickEventSpy).toHaveBeenCalled(); + }); + + it('reuses args from composeStories', () => { + const { getByText } = render(CSF3Primary()); + const buttonElement = getByText(/foo/i); + expect(buttonElement).toBeInTheDocument(); + }); -it('reuses args from composeStories', () => { - const { getByText } = render(CSF3Primary()); - const buttonElement = getByText(/foo/i); - expect(buttonElement).toBeInTheDocument(); + it('should call and compose loaders data', async () => { + await LoaderStory.load(); + const { getByTestId } = render(LoaderStory()); + expect(getByTestId('spy-data').textContent).toEqual('mockFn return value'); + expect(getByTestId('loaded-data').textContent).toEqual('loaded data'); + // spy assertions happen in the play function and should work + await LoaderStory.play!(); + }); }); describe('projectAnnotations', () => { it('renders with default projectAnnotations', () => { + setProjectAnnotations([ + { + parameters: { injected: true }, + globalTypes: { + locale: { defaultValue: 'en' }, + }, + }, + ]); const WithEnglishText = composeStory(stories.CSF2StoryWithLocale, stories.default); const { getByText } = render(WithEnglishText()); const buttonElement = getByText('Hello!'); @@ -51,7 +70,7 @@ describe('projectAnnotations', () => { it('renders with custom projectAnnotations via composeStory params', () => { const WithPortugueseText = composeStory(stories.CSF2StoryWithLocale, stories.default, { - globalTypes: { locale: { defaultValue: 'pt' } } as any, + globals: { locale: 'pt' }, }); const { getByText } = render(WithPortugueseText()); const buttonElement = getByText('Olá!'); @@ -84,9 +103,9 @@ describe('CSF3', () => { it('renders with play function', async () => { const CSF3InputFieldFilled = composeStory(stories.CSF3InputFieldFilled, stories.default); - render(CSF3InputFieldFilled()); + const { container } = render(CSF3InputFieldFilled()); - await CSF3InputFieldFilled.play!(); + await CSF3InputFieldFilled.play!({ canvasElement: container as HTMLElement }); const input = screen.getByTestId('input') as HTMLInputElement; expect(input.value).toEqual('Hello world!'); @@ -127,14 +146,14 @@ describe('ComposeStories types', () => { // Batch snapshot testing const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName, Story]); it.each(testCases)('Renders %s story', async (_storyName, Story) => { - if (typeof Story === 'string' || _storyName === 'CSF2StoryWithParamsAndDecorator') { + if (typeof Story === 'string' || _storyName === 'CSF2StoryWithLocale') { return; } + await Story.load(); + const { container, baseElement } = await render(Story()); + await Story.play?.({ canvasElement: container as HTMLElement }); await new Promise((resolve) => setTimeout(resolve, 0)); - const { baseElement } = await render(Story()); - await Story.play?.(); - expect(baseElement).toMatchSnapshot(); }); From 6dae91d30c8d9fc60db1169d2e0be2e225dc9d13 Mon Sep 17 00:00:00 2001 From: Yann Braga Date: Mon, 4 Mar 2024 11:54:37 +0100 Subject: [PATCH 11/11] add migration note regarding project annotation overrides --- MIGRATION.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/MIGRATION.md b/MIGRATION.md index 62aef1593d05..5919ec501c42 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -2,6 +2,7 @@ - [From version 7.x to 8.0.0](#from-version-7x-to-800) - [Portable stories](#portable-stories) + - [Project annotations are now merged instead of overwritten in composeStory](#project-annotations-are-now-merged-instead-of-overwritten-in-composestory) - [Type change in `composeStories` API](#type-change-in-composestories-api) - [DOM structure changed in portable stories](#dom-structure-changed-in-portable-stories) - [Tab addons are now routed to a query parameter](#tab-addons-are-now-routed-to-a-query-parameter) @@ -405,6 +406,21 @@ ### Portable stories +#### Project annotations are now merged instead of overwritten in composeStory + +When passing project annotations overrides via `composeStory` such as: + +```tsx +const projectAnnotationOverrides = { parameters: { foo: "bar" } }; +const Primary = composeStory( + stories.Primary, + stories, + projectAnnotationOverrides +); +``` + +they are now merged with the annotations passed via `setProjectAnnotations` rather than completely overwriting them. This was seen as a bug and it's now fixed. If you have a use case where you really need this, please open an issue to elaborate. + #### Type change in `composeStories` API There is a TypeScript type change in the `play` function returned from `composeStories` or `composeStory` in `@storybook/react` or `@storybook/vue3`, where before it was always defined, now it is potentially undefined. This means that you might have to make a small change in your code, such as: