Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Portable stories: Improve existing APIs, add loaders support #26267

Merged
merged 11 commits into from
Mar 4, 2024
78 changes: 63 additions & 15 deletions MIGRATION.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
<h1>Migration</h1>

- [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)
- [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)
- [Default keyboard shortcuts changed](#default-keyboard-shortcuts-changed)
- [Manager addons are now rendered with React 18](#manager-addons-are-now-rendered-with-react-18)
Expand Down Expand Up @@ -86,17 +89,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)
Expand All @@ -122,7 +125,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)
Expand All @@ -136,7 +139,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)
Expand Down Expand Up @@ -186,7 +189,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)
Expand Down Expand Up @@ -401,7 +404,24 @@

## From version 7.x to 8.0.0

### Type change in `composeStories` API
### 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:

Expand All @@ -418,6 +438,35 @@ 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.

#### DOM structure changed in portable stories

The portable stories API now adds a wrapper to your stories with a unique id based on your story id, such as:

```html
<div data-story="true" id="#storybook-story-button--primary">
<!-- your story here -->
</div>
```

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(<Primary />);
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`.
Expand Down Expand Up @@ -556,7 +605,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,
Expand Down
1 change: 1 addition & 0 deletions code/lib/preview-api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export {
filterArgTypes,
sanitizeStoryContextUpdate,
setProjectAnnotations,
getPortableStoryWrapperId,
inferControls,
userOrAutoTitleFromSpecifier,
userOrAutoTitle,
Expand Down
128 changes: 114 additions & 14 deletions code/lib/preview-api/src/modules/store/csf/portable-stories.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>;

// Most integration tests for this functionality are located under renderers/react
describe('composeStory', () => {
const meta = {
const meta: Meta = {
title: 'Button',
parameters: {
firstAddon: true,
Expand All @@ -15,25 +23,43 @@ 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);
expect(composedStory.args).toEqual({ ...Story.args, ...meta.args });
expect(composedStory.parameters).toEqual(
expect.objectContaining({ ...Story.parameters, ...meta.parameters })
);

composedStory();

expect(decoratorFromProjectAnnotations).toHaveBeenCalledOnce();
expect(decoratorFromStoryAnnotations).toHaveBeenCalledOnce();
});

it('should compose with a play function', async () => {
const spy = vi.fn();
const Story = () => {};
const Story: Story = () => {};
Story.args = {
primary: true,
};
Expand All @@ -53,6 +79,80 @@ 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 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)
Expand All @@ -62,7 +162,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',
},
Expand All @@ -72,7 +172,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',
},
Expand All @@ -83,7 +183,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');
});
});
});
Expand All @@ -93,7 +193,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',
},
Expand All @@ -118,7 +218,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/,
Expand All @@ -131,7 +231,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/,
Expand All @@ -149,7 +249,7 @@ describe('composeStories', () => {
});

it('should filter non-story exports with includeStories', () => {
const StoryModuleWithNonStoryExports = {
const StoryModuleWithNonStoryExports: StoriesModule = {
default: {
title: 'Some/Component',
includeStories: /.*Story/,
Expand Down
Loading
Loading