Skip to content

Commit

Permalink
Merge pull request #26027 from storybookjs/feature/portable-stories-l…
Browse files Browse the repository at this point in the history
…oaders

Portable stories: Add support for loaders
  • Loading branch information
yannbf authored Feb 14, 2024
2 parents 1cc9d97 + e335a31 commit 869f44d
Show file tree
Hide file tree
Showing 10 changed files with 260 additions and 47 deletions.
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import { describe, expect, vi, it } from 'vitest';
import type {
ComponentAnnotations as Meta,
StoryAnnotationsOrFn as Story,
Store_CSFExports,
} from '@storybook/types';
import { composeStory, composeStories } 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 @@ -14,8 +21,57 @@ 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();
expect(loadSpy).toHaveBeenCalled();
composedStory();
});

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 return story with composed args and parameters', () => {
const Story = () => {};
const Story: Story = () => {};
Story.args = { primary: true };
Story.parameters = {
parameters: {
Expand All @@ -32,7 +88,7 @@ describe('composeStory', () => {

it('should compose with a play function', async () => {
const spy = vi.fn();
const Story = () => {};
const Story: Story = () => {};
Story.args = {
primary: true,
};
Expand Down Expand Up @@ -61,7 +117,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 @@ -71,7 +127,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 @@ -92,7 +148,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 @@ -117,7 +173,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 @@ -130,7 +186,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 @@ -148,7 +204,7 @@ describe('composeStories', () => {
});

it('should filter non-story exports with includeStories', () => {
const StoryModuleWithNonStoryExports = {
const StoryModuleWithNonStoryExports: StoriesModule = {
default: {
title: 'Some/Component',
includeStories: /.*Story/,
Expand Down
14 changes: 9 additions & 5 deletions code/lib/preview-api/src/modules/store/csf/portable-stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,16 +87,20 @@ export function composeStory<TRenderer extends Renderer = Renderer, TArgs extend
};

const composedStory: ComposedStoryFn<TRenderer, Partial<TArgs>> = Object.assign(
(extraArgs?: Partial<TArgs>) => {
const finalContext: StoryContext<TRenderer> = {
...context,
args: { ...context.initialArgs, ...extraArgs },
function storyFn(extraArgs?: Partial<TArgs>) {
context.args = {
...context.initialArgs,
...extraArgs,
};

return story.unboundStoryFn(prepareContext(finalContext));
return story.unboundStoryFn(prepareContext(context));
},
{
storyName,
load: async () => {
const loadedContext = await story.applyLoaders(context);
context.loaded = loadedContext.loaded;
},
args: story.initialArgs as Partial<TArgs>,
parameters: story.parameters as Parameters,
argTypes: story.argTypes as StrictArgTypes<TArgs>,
Expand Down
1 change: 1 addition & 0 deletions code/lib/types/src/modules/composedStory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export type ComposedStoryFn<
TRenderer extends Renderer = Renderer,
TArgs = Args,
> = PartialArgsStoryFn<TRenderer, TArgs> & {
load: () => Promise<void>;
play: ComposedStoryPlayFn<TRenderer, TArgs> | undefined;
args: TArgs;
id: StoryId;
Expand Down
33 changes: 31 additions & 2 deletions code/renderers/react/src/__test__/Button.stories.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -84,7 +84,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 spyFn = fn();
export const LoaderStory: CSF3Story<{ spyFn: (val: string) => string }> = {
args: {
spyFn,
},
loaders: [
async () => {
spyFn.mockReturnValueOnce('baz');
return {
value: 'bar',
};
},
],
render: (args, { loaded }) => {
const data = args.spyFn('foo');
return (
<div>
<div data-testid="loaded-data">{loaded.value}</div>
<div data-testid="spy-data">{String(data)}</div>
</div>
);
},
play: async () => {
expect(spyFn).toHaveBeenCalledWith('foo');
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,22 @@ exports[`Renders CSF3Primary story 1`] = `
</div>
</body>
`;

exports[`Renders LoaderStory story 1`] = `
<body>
<div>
<div>
<div
data-testid="loaded-data"
>
bar
</div>
<div
data-testid="spy-data"
>
baz
</div>
</div>
</div>
</body>
`;
21 changes: 17 additions & 4 deletions code/renderers/react/src/__test__/portable-stories.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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, container } = render(<LoaderStory />);
expect(getByTestId('spy-data').textContent).toEqual('baz');
expect(getByTestId('loaded-data').textContent).toEqual('bar');
// spy assertions happen in the play function and should work
await LoaderStory.play!({ canvasElement: container as HTMLElement });
});
});

describe('projectAnnotations', () => {
Expand Down Expand Up @@ -139,9 +148,13 @@ 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(<Story />);
expect(tree.baseElement).toMatchSnapshot();
await Story.load();
const { container, baseElement } = await render(<Story />);
await Story.play?.({ canvasElement: container });
expect(baseElement).toMatchSnapshot();
});
36 changes: 34 additions & 2 deletions code/renderers/vue3/src/__tests__/composeStories/Button.stories.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -114,7 +114,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 spyFn = fn();
export const LoaderStory: StoryObj<{ spyFn: (val: string) => string }> = {
args: {
spyFn,
},
loaders: [
async () => {
spyFn.mockReturnValueOnce('baz');
return {
value: 'bar',
};
},
],
render: (args, { loaded }) => ({
components: { Button },
setup() {
return { args, data: args.spyFn('foo'), loaded: loaded.value };
},
template: `
<div>
<div data-testid="loaded-data">{{loaded}}</div>
<div data-testid="spy-data">{{data}}</div>
</div>
`,
}),
play: async () => {
expect(spyFn).toHaveBeenCalledWith('foo');
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,22 @@ exports[`Renders CSF3Primary story 1`] = `
</div>
</body>
`;

exports[`Renders LoaderStory story 1`] = `
<body>
<div>
<div>
<div
data-testid="loaded-data"
>
bar
</div>
<div
data-testid="spy-data"
>
baz
</div>
</div>
</div>
</body>
`;
Loading

0 comments on commit 869f44d

Please sign in to comment.