From e1feeca65e1447eed7f2670616fca56ca06c450f Mon Sep 17 00:00:00 2001
From: Yann Braga
Date: Wed, 14 Feb 2024 10:30:13 +0100
Subject: [PATCH 1/2] Portable stories: Add support for loaders
---
.../store/csf/portable-stories.test.ts | 74 ++++++++++++++++---
.../src/modules/store/csf/portable-stories.ts | 14 ++--
code/lib/types/src/modules/composedStory.ts | 1 +
3 files changed, 75 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 2ad7f7500f5b..2dd231550b7b 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,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;
+
// Most integration tests for this functionality are located under renderers/react
describe('composeStory', () => {
- const meta = {
+ const meta: Meta = {
title: 'Button',
parameters: {
firstAddon: true,
@@ -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,
+ render: (_args, { loaded }) => {
+ expect(loaded).toEqual({ foo: 'bar' });
+ },
+ loaders: [
+ async (context) => {
+ loadSpy();
+ expect(context.args).toEqual(args);
+ return {
+ 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(spyFn).toHaveBeenCalled();
+ expect(data).toBe('mockedData');
+ },
+ };
+
+ const composedStory = composeStory(Story, {});
+ await composedStory.load();
+ composedStory();
+ });
+
it('should return story with composed args and parameters', () => {
- const Story = () => {};
+ const Story: Story = () => {};
Story.args = { primary: true };
Story.parameters = {
parameters: {
@@ -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,
};
@@ -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',
},
@@ -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',
},
@@ -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',
},
@@ -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/,
@@ -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/,
@@ -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/,
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..bd2dd421ccfe 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
@@ -87,16 +87,20 @@ export function composeStory> = Object.assign(
- (extraArgs?: Partial) => {
- const finalContext: StoryContext = {
- ...context,
- args: { ...context.initialArgs, ...extraArgs },
+ function storyFn(extraArgs?: Partial) {
+ context.args = {
+ ...context.args,
+ ...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,
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 5ce61bc678e8..24bf673cabe6 100644
--- a/code/lib/types/src/modules/composedStory.ts
+++ b/code/lib/types/src/modules/composedStory.ts
@@ -55,6 +55,7 @@ export type ComposedStoryFn<
TRenderer extends Renderer = Renderer,
TArgs = Args,
> = PartialArgsStoryFn & {
+ load: () => Promise;
play: ComposedStoryPlayFn | undefined;
args: TArgs;
id: StoryId;
From e335a31c72e5f985ce2ca4831c1a927fe0d70016 Mon Sep 17 00:00:00 2001
From: Yann Braga
Date: Wed, 14 Feb 2024 14:28:18 +0100
Subject: [PATCH 2/2] Portable stories: Add tests for React and Vue renderers
---
.../store/csf/portable-stories.test.ts | 8 +--
.../src/modules/store/csf/portable-stories.ts | 2 +-
.../react/src/__test__/Button.stories.tsx | 33 +++++++++-
.../portable-stories.test.tsx.snap | 19 ++++++
.../src/__test__/portable-stories.test.tsx | 21 +++++--
.../composeStories/Button.stories.ts | 36 ++++++++++-
.../portable-stories.test.ts.snap | 19 ++++++
.../composeStories/portable-stories.test.ts | 61 +++++++++++--------
.../src/stories/Button.stories.tsx | 29 ++++++++-
9 files changed, 190 insertions(+), 38 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 2dd231550b7b..8e0452cc41fc 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
@@ -26,9 +26,6 @@ describe('composeStory', () => {
const args = { story: 'story' };
const LoaderStory: Story = {
args,
- render: (_args, { loaded }) => {
- expect(loaded).toEqual({ foo: 'bar' });
- },
loaders: [
async (context) => {
loadSpy();
@@ -38,6 +35,9 @@ describe('composeStory', () => {
};
},
],
+ render: (_args, { loaded }) => {
+ expect(loaded).toEqual({ foo: 'bar' });
+ },
};
const composedStory = composeStory(LoaderStory, {});
@@ -60,7 +60,6 @@ describe('composeStory', () => {
],
render: (args) => {
const data = args.spyFn();
- expect(spyFn).toHaveBeenCalled();
expect(data).toBe('mockedData');
},
};
@@ -68,6 +67,7 @@ describe('composeStory', () => {
const composedStory = composeStory(Story, {});
await composedStory.load();
composedStory();
+ expect(spyFn).toHaveBeenCalled();
});
it('should return story with composed args and parameters', () => {
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 bd2dd421ccfe..de45ab6af59b 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
@@ -89,7 +89,7 @@ export function composeStory> = Object.assign(
function storyFn(extraArgs?: Partial) {
context.args = {
- ...context.args,
+ ...context.initialArgs,
...extraArgs,
};
diff --git a/code/renderers/react/src/__test__/Button.stories.tsx b/code/renderers/react/src/__test__/Button.stories.tsx
index 6882b957b136..859a02ea9d1f 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';
@@ -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 (
+
+
{loaded.value}
+
{String(data)}
+
+ );
+ },
+ play: async () => {
+ expect(spyFn).toHaveBeenCalledWith('foo');
+ },
+};
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..018406c3581d 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
@@ -94,3 +94,22 @@ exports[`Renders CSF3Primary story 1`] = `
+
+
+
+