pathname: {pathname}
+
segment: {segment}
+
segments: {segments.join(',')}
searchparams:{' '}
@@ -52,6 +65,16 @@ function Component() {
))}
+
+ params:{' '}
+
+ {Object.entries(params).map(([key, value]) => (
+
+ {key}: {value}
+
+ ))}
+
+
{routerActions.map(({ cb, name }) => (
@@ -63,6 +86,8 @@ function Component() {
);
}
+type Story = StoryObj;
+
export default {
component: Component,
parameters: {
@@ -73,9 +98,54 @@ export default {
query: {
foo: 'bar',
},
+ prefetch: () => {
+ console.log('custom prefetch');
+ },
},
},
},
} as Meta;
-export const Default: StoryObj = {};
+export const Default: StoryObj = {
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement);
+ const routerMock = getRouter();
+
+ await step('Asserts whether forward hook is called', async () => {
+ const forwardBtn = await canvas.findByText('Go forward');
+ await userEvent.click(forwardBtn);
+ await expect(routerMock.forward).toHaveBeenCalled();
+ });
+
+ await step('Asserts whether custom prefetch hook is called', async () => {
+ const prefetchBtn = await canvas.findByText('Prefetch');
+ await userEvent.click(prefetchBtn);
+ await expect(routerMock.prefetch).toHaveBeenCalledWith('/prefetched-html');
+ });
+ },
+};
+
+export const WithSegmentDefined: Story = {
+ parameters: {
+ nextjs: {
+ appDirectory: true,
+ navigation: {
+ segments: ['dashboard', 'settings'],
+ },
+ },
+ },
+};
+
+export const WithSegmentDefinedForParams: Story = {
+ parameters: {
+ nextjs: {
+ appDirectory: true,
+ navigation: {
+ segments: [
+ ['slug', 'hello'],
+ ['framework', 'nextjs'],
+ ],
+ },
+ },
+ },
+};
diff --git a/code/frameworks/nextjs/template/stories_nextjs-default-ts/NextHeader.stories.tsx b/code/frameworks/nextjs/template/stories_nextjs-default-ts/NextHeader.stories.tsx
new file mode 100644
index 000000000000..d8abfe11bdd1
--- /dev/null
+++ b/code/frameworks/nextjs/template/stories_nextjs-default-ts/NextHeader.stories.tsx
@@ -0,0 +1,46 @@
+import NextHeader from './NextHeader';
+import type { Meta } from '@storybook/react';
+import type { StoryObj } from '@storybook/react';
+import { expect, userEvent, within } from '@storybook/test';
+import { cookies, headers } from '@storybook/nextjs/headers.mock';
+
+export default {
+ component: NextHeader,
+} as Meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ loaders: async () => {
+ cookies().set('firstName', 'Jane');
+ cookies().set({
+ name: 'lastName',
+ value: 'Doe',
+ });
+ headers().set('timezone', 'Central European Summer Time');
+ },
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement);
+ const headersMock = headers();
+ const cookiesMock = cookies();
+ await step('Cookie and header store apis are called upon rendering', async () => {
+ await expect(cookiesMock.getAll).toHaveBeenCalled();
+ await expect(headersMock.entries).toHaveBeenCalled();
+ });
+
+ await step('Upon clicking on submit, the user-id cookie is set', async () => {
+ const submitButton = await canvas.findByRole('button');
+ await userEvent.click(submitButton);
+
+ await expect(cookiesMock.set).toHaveBeenCalledWith('user-id', 'encrypted-id');
+ });
+
+ await step('The user-id cookie is available in cookie and header stores', async () => {
+ await expect(headersMock.get('cookie')).toContain('user-id=encrypted-id');
+ await expect(cookiesMock.get('user-id')).toEqual({
+ name: 'user-id',
+ value: 'encrypted-id',
+ });
+ });
+ },
+};
diff --git a/code/frameworks/nextjs/template/stories_nextjs-default-ts/NextHeader.tsx b/code/frameworks/nextjs/template/stories_nextjs-default-ts/NextHeader.tsx
new file mode 100644
index 000000000000..b93c9611c774
--- /dev/null
+++ b/code/frameworks/nextjs/template/stories_nextjs-default-ts/NextHeader.tsx
@@ -0,0 +1,39 @@
+import React from 'react';
+import { cookies, headers } from 'next/headers';
+
+export default async function Component() {
+ async function handleClick() {
+ 'use server';
+ cookies().set('user-id', 'encrypted-id');
+ }
+
+ return (
+ <>
+ Cookies:
+ {cookies()
+ .getAll()
+ .map(({ name, value }) => {
+ return (
+
+ Name: {name}
+ Value: {value}
+
+ );
+ })}
+
+ Headers:
+ {Array.from(headers().entries()).map(([name, value]: [string, string]) => {
+ return (
+
+ Name: {name}
+ Value: {value}
+
+ );
+ })}
+
+
+ >
+ );
+}
diff --git a/code/frameworks/nextjs/template/stories_nextjs-default-ts/Router.stories.tsx b/code/frameworks/nextjs/template/stories_nextjs-default-ts/Router.stories.tsx
new file mode 100644
index 000000000000..f5a840241fb4
--- /dev/null
+++ b/code/frameworks/nextjs/template/stories_nextjs-default-ts/Router.stories.tsx
@@ -0,0 +1,108 @@
+import React from 'react';
+import type { Meta, StoryObj } from '@storybook/react';
+import { expect, within, userEvent } from '@storybook/test';
+import { getRouter } from '@storybook/nextjs/router.mock';
+import Router, { useRouter } from 'next/router';
+
+function Component() {
+ const router = useRouter();
+ const searchParams = router.query;
+
+ const routerActions = [
+ {
+ cb: () => router.back(),
+ name: 'Go back',
+ },
+ {
+ cb: () => router.forward(),
+ name: 'Go forward',
+ },
+ {
+ cb: () => router.prefetch('/prefetched-html'),
+ name: 'Prefetch',
+ },
+ {
+ // @ts-expect-error (old API)
+ cb: () => router.push('/push-html', { forceOptimisticNavigation: true }),
+ name: 'Push HTML',
+ },
+ {
+ // @ts-expect-error (old API)
+ cb: () => router.replace('/replaced-html', { forceOptimisticNavigation: true }),
+ name: 'Replace',
+ },
+ ];
+
+ return (
+
+
Router pathname: {Router.pathname}
+
pathname: {router.pathname}
+
+ searchparams:{' '}
+
+ {Object.entries(searchParams).map(([key, value]) => (
+
+ {key}: {value}
+
+ ))}
+
+
+ {routerActions.map(({ cb, name }) => (
+
+
+ {name}
+
+
+ ))}
+
+ );
+}
+
+export default {
+ component: Component,
+ parameters: {
+ nextjs: {
+ router: {
+ pathname: '/hello',
+ query: {
+ foo: 'bar',
+ },
+ prefetch: () => {
+ console.log('custom prefetch');
+ },
+ },
+ },
+ },
+} as Meta;
+
+export const Default: StoryObj = {
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement);
+ const routerMock = getRouter();
+
+ await step('Router property overrides should be available in useRouter fn', async () => {
+ await expect(Router.pathname).toBe('/hello');
+ await expect(Router.query).toEqual({ foo: 'bar' });
+ });
+
+ await step(
+ 'Router property overrides should be available in default export from next/router',
+ async () => {
+ await expect(Router.pathname).toBe('/hello');
+ await expect(Router.query).toEqual({ foo: 'bar' });
+ }
+ );
+
+ await step('Asserts whether forward hook is called', async () => {
+ const forwardBtn = await canvas.findByText('Go forward');
+ await userEvent.click(forwardBtn);
+ await expect(routerMock.forward).toHaveBeenCalled();
+ });
+
+ await step('Asserts whether custom prefetch hook is called', async () => {
+ const prefetchBtn = await canvas.findByText('Prefetch');
+ await userEvent.click(prefetchBtn);
+ await expect(routerMock.prefetch).toHaveBeenCalledWith('/prefetched-html');
+ });
+ },
+};
diff --git a/code/frameworks/nextjs/template/stories_nextjs-default-ts/ServerActions.stories.tsx b/code/frameworks/nextjs/template/stories_nextjs-default-ts/ServerActions.stories.tsx
new file mode 100644
index 000000000000..17d364429726
--- /dev/null
+++ b/code/frameworks/nextjs/template/stories_nextjs-default-ts/ServerActions.stories.tsx
@@ -0,0 +1,75 @@
+import React from 'react';
+import type { Meta, StoryObj } from '@storybook/react';
+import { expect, within, userEvent } from '@storybook/test';
+import { cookies } from '@storybook/nextjs/headers.mock';
+import { revalidatePath } from '@storybook/nextjs/cache.mock';
+import { redirect } from '@storybook/nextjs/navigation.mock';
+
+import { accessRoute, login, logout } from './server-actions';
+
+function Component() {
+ return (
+
+
+
+
+
+ );
+}
+
+export default {
+ component: Component,
+} as Meta;
+
+export const Default: StoryObj = {
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement);
+
+ const loginBtn = canvas.getByText('Login');
+ const logoutBtn = canvas.getByText('Logout');
+ const accessRouteBtn = canvas.getByText('Access protected route');
+
+ await step('accessRoute flow - logged out', async () => {
+ await userEvent.click(accessRouteBtn);
+ await expect(cookies().get).toHaveBeenCalledWith('user');
+ await expect(redirect).toHaveBeenCalledWith('/');
+ });
+
+ await step('accessRoute flow - logged', async () => {
+ cookies.mockRestore();
+ cookies().set('user', 'storybookjs');
+ await userEvent.click(accessRouteBtn);
+ await expect(cookies().get).toHaveBeenCalledWith('user');
+ await expect(revalidatePath).toHaveBeenCalledWith('/');
+ await expect(redirect).toHaveBeenCalledWith('/protected');
+ });
+
+ await step('logout flow', async () => {
+ cookies.mockRestore();
+ await userEvent.click(logoutBtn);
+ await expect(cookies().delete).toHaveBeenCalled();
+ await expect(revalidatePath).toHaveBeenCalledWith('/');
+ await expect(redirect).toHaveBeenCalledWith('/');
+ });
+
+ await step('login flow', async () => {
+ cookies.mockRestore();
+ await userEvent.click(loginBtn);
+ await expect(cookies().set).toHaveBeenCalledWith('user', 'storybookjs');
+ await expect(revalidatePath).toHaveBeenCalledWith('/');
+ await expect(redirect).toHaveBeenCalledWith('/');
+ });
+ },
+};
diff --git a/code/frameworks/nextjs/template/stories_nextjs-default-ts/server-actions.tsx b/code/frameworks/nextjs/template/stories_nextjs-default-ts/server-actions.tsx
new file mode 100644
index 000000000000..ffeba72ab848
--- /dev/null
+++ b/code/frameworks/nextjs/template/stories_nextjs-default-ts/server-actions.tsx
@@ -0,0 +1,28 @@
+'use server';
+
+import { cookies } from 'next/headers';
+import { revalidatePath } from 'next/cache';
+import { redirect } from 'next/navigation';
+
+export async function accessRoute() {
+ const user = cookies().get('user');
+
+ if (!user) {
+ redirect('/');
+ }
+
+ revalidatePath('/');
+ redirect(`/protected`);
+}
+
+export async function logout() {
+ cookies().delete('user');
+ revalidatePath('/');
+ redirect('/');
+}
+
+export async function login() {
+ cookies().set('user', 'storybookjs');
+ revalidatePath('/');
+ redirect('/');
+}
diff --git a/code/lib/cli/package.json b/code/lib/cli/package.json
index 200eae0d10e8..1ea9d9ca1181 100644
--- a/code/lib/cli/package.json
+++ b/code/lib/cli/package.json
@@ -56,8 +56,8 @@
"prep": "node --loader ../../../scripts/node_modules/esbuild-register/loader.js -r ../../../scripts/node_modules/esbuild-register/register.js ../../../scripts/prepare/bundle.ts"
},
"dependencies": {
- "@babel/core": "^7.23.0",
- "@babel/types": "^7.23.0",
+ "@babel/core": "^7.24.4",
+ "@babel/types": "^7.24.0",
"@ndelangen/get-tarball": "^3.0.7",
"@storybook/codemod": "workspace:*",
"@storybook/core-common": "workspace:*",
diff --git a/code/lib/cli/src/automigrate/fixes/vta.ts b/code/lib/cli/src/automigrate/fixes/vta.ts
index c86f957298a8..ef4dd78d0bb5 100644
--- a/code/lib/cli/src/automigrate/fixes/vta.ts
+++ b/code/lib/cli/src/automigrate/fixes/vta.ts
@@ -48,7 +48,7 @@ export const vta: Fix = {
await updateMainConfig({ mainConfigPath, dryRun: !!dryRun }, async (main) => {
logger.info(`✅ Adding "@chromatic-com/storybook" addon`);
- main.appendValueToArray(['addons'], '@chromatic-dom/storybook');
+ main.appendValueToArray(['addons'], '@chromatic-com/storybook');
});
}
},
diff --git a/code/lib/codemod/package.json b/code/lib/codemod/package.json
index 10351aaf7757..e1d2fdff65b5 100644
--- a/code/lib/codemod/package.json
+++ b/code/lib/codemod/package.json
@@ -54,10 +54,10 @@
"prep": "node --loader ../../../scripts/node_modules/esbuild-register/loader.js -r ../../../scripts/node_modules/esbuild-register/register.js ../../../scripts/prepare/bundle.ts"
},
"dependencies": {
- "@babel/core": "^7.23.2",
- "@babel/preset-env": "^7.23.2",
- "@babel/types": "^7.23.0",
- "@storybook/csf": "^0.1.2",
+ "@babel/core": "^7.24.4",
+ "@babel/preset-env": "^7.24.4",
+ "@babel/types": "^7.24.0",
+ "@storybook/csf": "^0.1.6",
"@storybook/csf-tools": "workspace:*",
"@storybook/node-logger": "workspace:*",
"@storybook/types": "workspace:*",
diff --git a/code/lib/core-common/package.json b/code/lib/core-common/package.json
index b40d3bba403e..2cef151d5e0e 100644
--- a/code/lib/core-common/package.json
+++ b/code/lib/core-common/package.json
@@ -65,6 +65,7 @@
"node-fetch": "^2.0.0",
"picomatch": "^2.3.0",
"pkg-dir": "^5.0.0",
+ "prettier-fallback": "npm:prettier@^3",
"pretty-hrtime": "^1.0.3",
"resolve-from": "^5.0.0",
"semver": "^7.3.7",
@@ -80,12 +81,23 @@
"@types/node": "^18.0.0",
"@types/node-fetch": "^2.6.4",
"@types/picomatch": "^2.3.0",
+ "@types/prettier-v2": "npm:@types/prettier@^2",
"@types/pretty-hrtime": "^1.0.0",
"mock-fs": "^5.2.0",
+ "prettier-v2": "npm:prettier@^2",
+ "prettier-v3": "npm:prettier@^3",
"slash": "^5.0.0",
"type-fest": "~2.19",
"typescript": "^5.3.2"
},
+ "peerDependencies": {
+ "prettier": "^2 || ^3"
+ },
+ "peerDependenciesMeta": {
+ "prettier": {
+ "optional": true
+ }
+ },
"publishConfig": {
"access": "public"
},
diff --git a/code/lib/core-common/src/index.ts b/code/lib/core-common/src/index.ts
index a568fa70ba4a..d16c9118ae8b 100644
--- a/code/lib/core-common/src/index.ts
+++ b/code/lib/core-common/src/index.ts
@@ -37,6 +37,7 @@ export * from './utils/validate-config';
export * from './utils/validate-configuration-files';
export * from './utils/satisfies';
export * from './utils/strip-abs-node-modules-path';
+export * from './utils/formatter';
export * from './js-package-manager';
import versions from './versions';
diff --git a/code/lib/core-common/src/js-package-manager/Yarn2Proxy.ts b/code/lib/core-common/src/js-package-manager/Yarn2Proxy.ts
index 8ed0de57e826..db4bb886c461 100644
--- a/code/lib/core-common/src/js-package-manager/Yarn2Proxy.ts
+++ b/code/lib/core-common/src/js-package-manager/Yarn2Proxy.ts
@@ -36,7 +36,8 @@ const CRITICAL_YARN2_ERROR_CODES = {
YN0083: 'AUTOMERGE_GIT_ERROR',
};
-// @ts-expect-error The error codes might be helpful in the future
+// @ts-expect-error If we want a code to be parsed, we move from the list below to the list above
+// Keep the codes here, they might be helpful in the future
const YARN2_ERROR_CODES = {
...CRITICAL_YARN2_ERROR_CODES,
YN0000: 'UNNAMED',
@@ -80,7 +81,7 @@ const YARN2_ERROR_CODES = {
YN0090: 'OFFLINE_MODE_ENABLED',
};
-// This encompasses both yarn 2 and yarn 3
+// This encompasses Yarn Berry (v2+)
export class Yarn2Proxy extends JsPackageManager {
readonly type = 'yarn2';
diff --git a/code/lib/core-common/src/utils/__snapshots__/formatter.test.ts.snap b/code/lib/core-common/src/utils/__snapshots__/formatter.test.ts.snap
new file mode 100644
index 000000000000..5d3c9cb0526d
--- /dev/null
+++ b/code/lib/core-common/src/utils/__snapshots__/formatter.test.ts.snap
@@ -0,0 +1,76 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`formatter > withPrettierConfig > prettier-v2 > formats content with prettier 1`] = `
+"import type { Meta, StoryObj } from '@storybook/nextjs';
+
+import Component from './foo';
+
+const meta = {
+ component: Component,
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+"
+`;
+
+exports[`formatter > withPrettierConfig > prettier-v3 > formats content with prettier 1`] = `
+"import type { Meta, StoryObj } from '@storybook/nextjs';
+
+import Component from './foo';
+
+const meta = {
+ component: Component,
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+"
+`;
+
+exports[`formatter > withoutPrettierConfigAndWithEditorConfig > prettier not available > should return the content formatted by settings of editorconfig 1`] = `
+"import type { Meta, StoryObj } from "@storybook/nextjs";
+
+import Component from "./foo";
+
+const meta = {
+ component: Component,
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+"
+`;
+
+exports[`formatter > withoutPrettierConfigAndWithEditorConfig > prettier-v2 > formats content with prettier 1`] = `
+"import type { Meta, StoryObj } from "@storybook/nextjs";
+
+import Component from "./foo";
+
+const meta = {
+ component: Component,
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+"
+`;
+
+exports[`formatter > withoutPrettierConfigAndWithEditorConfig > prettier-v3 > formats content with prettier 1`] = `
+"import type { Meta, StoryObj } from "@storybook/nextjs";
+
+import Component from "./foo";
+
+const meta = {
+ component: Component,
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+"
+`;
diff --git a/code/lib/core-common/src/utils/__tests-formatter__/withPrettierConfig/.prettierrc b/code/lib/core-common/src/utils/__tests-formatter__/withPrettierConfig/.prettierrc
new file mode 100644
index 000000000000..f9148847022b
--- /dev/null
+++ b/code/lib/core-common/src/utils/__tests-formatter__/withPrettierConfig/.prettierrc
@@ -0,0 +1,6 @@
+{
+ "trailingComma": "es5",
+ "tabWidth": 4,
+ "semi": true,
+ "singleQuote": true
+}
diff --git a/code/lib/core-common/src/utils/__tests-formatter__/withoutEditorConfig/.editorconfig b/code/lib/core-common/src/utils/__tests-formatter__/withoutEditorConfig/.editorconfig
new file mode 100644
index 000000000000..c5319e7de1f8
--- /dev/null
+++ b/code/lib/core-common/src/utils/__tests-formatter__/withoutEditorConfig/.editorconfig
@@ -0,0 +1,3 @@
+root = true
+
+[*]
diff --git a/code/lib/core-common/src/utils/__tests-formatter__/withoutEditorConfig/.prettierrc b/code/lib/core-common/src/utils/__tests-formatter__/withoutEditorConfig/.prettierrc
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/code/lib/core-common/src/utils/__tests-formatter__/withoutPrettierConfig/.editorconfig b/code/lib/core-common/src/utils/__tests-formatter__/withoutPrettierConfig/.editorconfig
new file mode 100644
index 000000000000..6094488126d1
--- /dev/null
+++ b/code/lib/core-common/src/utils/__tests-formatter__/withoutPrettierConfig/.editorconfig
@@ -0,0 +1,8 @@
+root = true
+
+[*]
+end_of_line = lf
+insert_final_newline = true
+indent_style = space
+indent_size = 6
+
diff --git a/code/lib/core-common/src/utils/__tests-formatter__/withoutPrettierConfig/.prettierrc b/code/lib/core-common/src/utils/__tests-formatter__/withoutPrettierConfig/.prettierrc
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/code/lib/core-common/src/utils/formatter.test.ts b/code/lib/core-common/src/utils/formatter.test.ts
new file mode 100644
index 000000000000..3f8eaf003478
--- /dev/null
+++ b/code/lib/core-common/src/utils/formatter.test.ts
@@ -0,0 +1,183 @@
+import { formatFileContent } from './formatter';
+import { describe, it, expect, vi } from 'vitest';
+import path from 'node:path';
+
+const mockPrettier = vi.hoisted(() => ({
+ resolveConfig: vi.fn(),
+ format: vi.fn(),
+ version: vi.fn(),
+}));
+
+vi.mock('prettier', () => ({
+ resolveConfig: mockPrettier.resolveConfig,
+ format: mockPrettier.format,
+ get version() {
+ return mockPrettier.version();
+ },
+}));
+
+const dummyContent = `
+import type { Meta, StoryObj } from '@storybook/nextjs'
+
+import Component from './foo';
+
+ const meta = {
+ component: Component
+ } satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+`;
+
+describe('formatter', () => {
+ describe('withPrettierConfig', () => {
+ const testPath = path.resolve(__dirname, '__tests-formatter__', 'withPrettierConfig');
+
+ describe('prettier-v2', async () => {
+ const prettierV2 = await import('prettier-v2');
+
+ it('formats content with prettier', async () => {
+ mockPrettier.format.mockImplementation(prettierV2.format);
+ mockPrettier.version.mockReturnValue(prettierV2.version);
+ mockPrettier.resolveConfig.mockImplementation(prettierV2.resolveConfig);
+
+ const filePath = path.resolve(testPath, 'testFile.ts');
+
+ const result = await formatFileContent(filePath, dummyContent);
+
+ expect(result).toMatchSnapshot();
+ });
+ });
+
+ describe('prettier-v3', async () => {
+ const prettierV3 = await import('prettier-v3');
+
+ it('formats content with prettier', async () => {
+ mockPrettier.format.mockImplementation(prettierV3.format);
+ mockPrettier.version.mockReturnValue(prettierV3.version);
+ mockPrettier.resolveConfig.mockImplementation(prettierV3.resolveConfig);
+
+ const filePath = path.resolve(testPath, 'testFile.ts');
+
+ const result = await formatFileContent(filePath, dummyContent);
+
+ expect(result).toMatchSnapshot();
+ });
+ });
+
+ describe('prettier not available', async () => {
+ it('should return the content as is', async () => {
+ mockPrettier.format.mockImplementation(() => {
+ throw new Error('Prettier not available');
+ });
+
+ const filePath = path.resolve(testPath, 'testFile.ts');
+
+ const result = await formatFileContent(filePath, dummyContent);
+
+ expect(result).toBe(dummyContent);
+ });
+ });
+ });
+
+ describe('withoutPrettierConfigAndWithEditorConfig', () => {
+ const testPath = path.resolve(__dirname, '__tests-formatter__', 'withoutPrettierConfig');
+
+ describe('prettier-v2', async () => {
+ const prettierV2 = await import('prettier-v2');
+
+ it('formats content with prettier', async () => {
+ mockPrettier.format.mockImplementation(prettierV2.format);
+ mockPrettier.version.mockReturnValue(prettierV2.version);
+ mockPrettier.resolveConfig.mockImplementation(prettierV2.resolveConfig);
+
+ const filePath = path.resolve(testPath, 'testFile.ts');
+
+ const result = await formatFileContent(filePath, dummyContent);
+
+ expect(result).toMatchSnapshot();
+ });
+ });
+
+ describe('prettier-v3', async () => {
+ const prettierV3 = await import('prettier-v3');
+
+ it('formats content with prettier', async () => {
+ mockPrettier.format.mockImplementation(prettierV3.format);
+ mockPrettier.version.mockReturnValue(prettierV3.version);
+ mockPrettier.resolveConfig.mockImplementation(prettierV3.resolveConfig);
+
+ const filePath = path.resolve(testPath, 'testFile.ts');
+
+ const result = await formatFileContent(filePath, dummyContent);
+
+ expect(result).toMatchSnapshot();
+ });
+ });
+
+ describe('prettier not available', async () => {
+ it('should return the content formatted by settings of editorconfig', async () => {
+ mockPrettier.format.mockImplementation(() => {
+ throw new Error('Prettier not available');
+ });
+
+ const filePath = path.resolve(testPath, 'testFile.ts');
+
+ const result = await formatFileContent(filePath, dummyContent);
+
+ expect(result).toMatchSnapshot();
+ });
+ });
+ });
+
+ describe('withoutPrettierConfigAndWithEditorConfig', () => {
+ const testPath = path.resolve(__dirname, '__tests-formatter__', 'withoutEditorConfig');
+
+ describe('prettier-v2', async () => {
+ const prettierV2 = await import('prettier-v2');
+
+ it('formats content with prettier', async () => {
+ mockPrettier.format.mockImplementation(prettierV2.format);
+ mockPrettier.version.mockReturnValue(prettierV2.version);
+ mockPrettier.resolveConfig.mockResolvedValue(null);
+
+ const filePath = path.resolve(testPath, 'testFile.ts');
+
+ const result = await formatFileContent(filePath, dummyContent);
+
+ expect(result).toBe(dummyContent);
+ });
+ });
+
+ describe('prettier-v3', async () => {
+ const prettierV3 = await import('prettier-v3');
+
+ it('formats content with prettier', async () => {
+ mockPrettier.format.mockImplementation(prettierV3.format);
+ mockPrettier.version.mockReturnValue(prettierV3.version);
+ mockPrettier.resolveConfig.mockResolvedValue(null);
+
+ const filePath = path.resolve(testPath, 'testFile.ts');
+
+ const result = await formatFileContent(filePath, dummyContent);
+
+ expect(result).toBe(dummyContent);
+ });
+ });
+
+ describe('prettier not available', async () => {
+ it('should return the content as is', async () => {
+ mockPrettier.format.mockImplementation(() => {
+ throw new Error('Prettier not available');
+ });
+
+ const filePath = path.resolve(testPath, 'testFile.ts');
+
+ const result = await formatFileContent(filePath, dummyContent);
+
+ expect(result).toBe(dummyContent);
+ });
+ });
+ });
+});
diff --git a/code/lib/core-common/src/utils/formatter.ts b/code/lib/core-common/src/utils/formatter.ts
new file mode 100644
index 000000000000..f18951cabf63
--- /dev/null
+++ b/code/lib/core-common/src/utils/formatter.ts
@@ -0,0 +1,93 @@
+import semver from 'semver';
+import dedent from 'ts-dedent';
+
+type Prettier = typeof import('prettier-v2') | typeof import('prettier-v3');
+type PrettierVersion = 2 | 3;
+
+let prettierInstance: Prettier | undefined;
+let prettierVersion: 2 | 3 | null = null;
+
+const getPrettier = async (): Promise<
+ | { instance: undefined; version: null }
+ | { instance: typeof import('prettier-v2'); version: 2 }
+ | { instance: typeof import('prettier-v3'); version: 3 }
+> => {
+ if (!prettierInstance) {
+ try {
+ prettierInstance = (await import('prettier')) as unknown as Prettier | undefined;
+ prettierVersion = prettierInstance?.version
+ ? (semver.major(prettierInstance.version) as PrettierVersion)
+ : null;
+
+ return {
+ version: prettierVersion,
+ instance: prettierInstance,
+ } as any;
+ } catch (err) {
+ return {
+ instance: undefined,
+ version: null,
+ };
+ }
+ }
+
+ return {
+ instance: prettierInstance,
+ version: prettierVersion,
+ } as any;
+};
+
+/**
+ * Format the content of a file using prettier.
+ * If prettier is not available in the user's project, it will fallback to use editorconfig settings if available and formats the file by a prettier-fallback.
+ */
+export async function formatFileContent(filePath: string, content: string): Promise {
+ try {
+ const prettier = await getPrettier();
+
+ switch (prettier.version) {
+ case 2:
+ case 3:
+ const config = await prettier.instance.resolveConfig(filePath);
+
+ if (!config || Object.keys(config).length === 0) {
+ return await formatWithEditorConfig(filePath, content);
+ }
+
+ const result = await prettier.instance.format(content, {
+ ...(config as any),
+ filepath: filePath,
+ });
+
+ return result;
+ case null:
+ case undefined:
+ return await formatWithEditorConfig(filePath, content);
+ default:
+ console.warn(dedent`
+ Your prettier version ${
+ (prettier as any).version
+ } is not supported to format files which were edited by Storybook.
+ Please raise an issue on the Storybook GitHub repository.
+ Falling back to EditorConfig settings, if available.
+ `);
+ return await formatWithEditorConfig(filePath, content);
+ }
+ } catch (error) {
+ return content;
+ }
+}
+
+async function formatWithEditorConfig(filePath: string, content: string) {
+ const prettier = await import('prettier-fallback');
+ const config = await prettier.resolveConfig(filePath, { editorconfig: true });
+
+ if (!config || Object.keys(config).length === 0) {
+ return content;
+ }
+
+ return prettier.format(content, {
+ ...(config as any),
+ filepath: filePath,
+ });
+}
diff --git a/code/lib/core-common/src/utils/paths.ts b/code/lib/core-common/src/utils/paths.ts
index 3895a69dd2b1..bb5896cc4f67 100644
--- a/code/lib/core-common/src/utils/paths.ts
+++ b/code/lib/core-common/src/utils/paths.ts
@@ -32,16 +32,19 @@ export const getProjectRoot = () => {
} catch (e) {
//
}
+
try {
- const found = findUp.sync('.yarn', { type: 'directory' });
- if (found) {
- result = result || path.join(found, '..');
- }
+ const splitDirname = __dirname.split('node_modules');
+ result = result || (splitDirname.length >= 2 ? splitDirname[0] : undefined);
} catch (e) {
//
}
+
try {
- result = result || __dirname.split('node_modules')[0];
+ const found = findUp.sync('.yarn', { type: 'directory' });
+ if (found) {
+ result = result || path.join(found, '..');
+ }
} catch (e) {
//
}
diff --git a/code/lib/core-common/templates/base-preview-body.html b/code/lib/core-common/templates/base-preview-body.html
index 094286c31b41..b6314fd75900 100644
--- a/code/lib/core-common/templates/base-preview-body.html
+++ b/code/lib/core-common/templates/base-preview-body.html
@@ -82,6 +82,38 @@ No Preview
-
-
+
+
+
+ The component failed to render properly, likely due to a configuration issue in Storybook.
+ Here are some common causes and how you can address them:
+
+
+
+ Missing Context/Providers : You can use decorators to supply specific
+ contexts or providers, which are sometimes necessary for components to render correctly. For
+ detailed instructions on using decorators, please visit the
+ Decorators documentation .
+
+
+ Misconfigured Webpack or Vite : Verify that Storybook picks up all necessary
+ settings for loaders, plugins, and other relevant parameters. You can find step-by-step
+ guides for configuring
+ Webpack or
+ Vite
+ with Storybook.
+
+
+ Missing Environment Variables : Your Storybook may require specific
+ environment variables to function as intended. You can set up custom environment variables
+ as outlined in the
+ Environment Variables documentation .
+
+
+
+
diff --git a/code/lib/core-common/templates/base-preview-head.html b/code/lib/core-common/templates/base-preview-head.html
index c4732d25281c..d76e3a5004d4 100644
--- a/code/lib/core-common/templates/base-preview-head.html
+++ b/code/lib/core-common/templates/base-preview-head.html
@@ -30,7 +30,8 @@
box-sizing: border-box;
margin: auto;
padding: 1rem;
- max-height: 100%; /* Hack for centering correctly in IE11 */
+ max-height: 100%;
+ /* Hack for centering correctly in IE11 */
}
/* Vertical centering fix for IE11 */
@@ -61,7 +62,9 @@
bottom: 0;
left: 0;
right: 0;
- padding: 20px;
+ box-sizing: border-box;
+
+ padding: 40px;
font-family:
'Nunito Sans',
-apple-system,
@@ -77,6 +80,18 @@
overflow: auto;
}
+ @media (max-width: 700px) {
+ .sb-wrapper {
+ padding: 20px;
+ }
+ }
+
+ @media (max-width: 500px) {
+ .sb-wrapper {
+ padding: 10px;
+ }
+ }
+
.sb-heading {
font-size: 14px;
font-weight: 600;
@@ -89,6 +104,7 @@
display: flex;
align-content: center;
justify-content: center;
+ box-sizing: border-box;
}
.sb-nopreview_main {
@@ -103,54 +119,133 @@
}
.sb-errordisplay {
- border: 20px solid rgb(187, 49, 49);
- background: #222;
- color: #fff;
+ background: #f6f9fc;
+ color: black;
z-index: 999999;
+ width: 100vw;
+ min-height: 100vh;
+ box-sizing: border-box;
+
+ & ol {
+ padding-left: 18px;
+ margin: 0;
+ }
+
+ & h1 {
+ font-family: Nunito Sans;
+ font-size: 22px;
+ font-weight: 400;
+ line-height: 30px;
+ font-weight: normal;
+ margin: 0;
+
+ &::before {
+ content: '';
+ display: inline-block;
+ width: 12px;
+ height: 12px;
+ background: #ff4400;
+ border-radius: 50%;
+ margin-right: 8px;
+ }
+ }
+
+ & p,
+ & ol {
+ font-family: Nunito Sans;
+ font-size: 14px;
+ font-weight: 400;
+ line-height: 19px;
+ margin: 0;
+ }
+
+ & li + li {
+ margin: 0;
+ padding: 0;
+ padding-top: 12px;
+ }
+
+ & a {
+ color: currentColor;
+ }
+ }
+
+ .sb-errordisplay_main {
+ margin: auto;
+ padding: 24px;
+ display: flex;
+ box-sizing: border-box;
+
+ flex-direction: column;
+ min-height: 100%;
+ width: 100%;
+ border-radius: 6px;
+ background: white;
+ border: 1px solid #ff0000;
+ box-shadow: 0 0 64px rgba(0, 0, 0, 0.1);
+ gap: 24px;
}
.sb-errordisplay_code {
padding: 10px;
- background: #000;
- color: #eee;
+ flex: 1;
+ background: #242424;
+ color: #c6c6c6;
+ box-sizing: border-box;
+
+ font-size: 14px;
+ font-weight: 400;
+ line-height: 19px;
+ border-radius: 4px;
+
font-family: 'Operator Mono', 'Fira Code Retina', 'Fira Code', 'FiraCode-Retina', 'Andale Mono',
'Lucida Console', Consolas, Monaco, monospace;
+ margin: 0;
+ overflow: auto;
}
.sb-errordisplay pre {
white-space: pre-wrap;
+ white-space: revert;
}
@-webkit-keyframes sb-rotate360 {
from {
transform: rotate(0deg);
}
+
to {
transform: rotate(360deg);
}
}
+
@keyframes sb-rotate360 {
from {
transform: rotate(0deg);
}
+
to {
transform: rotate(360deg);
}
}
+
@-webkit-keyframes sb-glow {
0%,
100% {
opacity: 1;
}
+
50% {
opacity: 0.4;
}
}
+
@keyframes sb-glow {
0%,
100% {
opacity: 1;
}
+
50% {
opacity: 0.4;
}
@@ -213,6 +308,7 @@
height: 14px;
width: 14px;
}
+
.sb-previewBlock_icon:last-child {
margin-left: auto;
}
@@ -234,23 +330,28 @@
text-align: left;
width: 100%;
}
+
.sb-argstableBlock th:first-of-type,
.sb-argstableBlock td:first-of-type {
padding-left: 20px;
}
+
.sb-argstableBlock th:nth-of-type(2),
.sb-argstableBlock td:nth-of-type(2) {
width: 35%;
}
+
.sb-argstableBlock th:nth-of-type(3),
.sb-argstableBlock td:nth-of-type(3) {
width: 15%;
}
+
.sb-argstableBlock th:last-of-type,
.sb-argstableBlock td:last-of-type {
width: 25%;
padding-right: 20px;
}
+
.sb-argstableBlock th span,
.sb-argstableBlock td span {
-webkit-animation: sb-glow 1.5s ease-in-out infinite;
@@ -260,6 +361,7 @@
box-shadow: none;
color: transparent;
}
+
.sb-argstableBlock th {
padding: 10px 15px;
}
@@ -270,35 +372,44 @@
rgba(0, 0, 0, 0.1) 0 1px 3px 1px,
rgba(0, 0, 0, 0.065) 0 0 0 1px;
}
+
.sb-argstableBlock-body tr {
background: transparent;
overflow: hidden;
}
+
.sb-argstableBlock-body tr:not(:first-child) {
border-top: 1px solid #e6e6e6;
}
+
.sb-argstableBlock-body tr:first-child td:first-child {
border-top-left-radius: 4px;
}
+
.sb-argstableBlock-body tr:first-child td:last-child {
border-top-right-radius: 4px;
}
+
.sb-argstableBlock-body tr:last-child td:first-child {
border-bottom-left-radius: 4px;
}
+
.sb-argstableBlock-body tr:last-child td:last-child {
border-bottom-right-radius: 4px;
}
+
.sb-argstableBlock-body td {
background: #fff;
padding-bottom: 10px;
padding-top: 10px;
vertical-align: top;
}
+
.sb-argstableBlock-body td:not(:first-of-type) {
padding-left: 15px;
padding-right: 15px;
}
+
.sb-argstableBlock-body button {
-webkit-animation: sb-glow 1.5s ease-in-out infinite;
animation: sb-glow 1.5s ease-in-out infinite;
diff --git a/code/lib/core-events/package.json b/code/lib/core-events/package.json
index 699640debd0d..8f8cff87bce0 100644
--- a/code/lib/core-events/package.json
+++ b/code/lib/core-events/package.json
@@ -78,6 +78,7 @@
"prep": "node --loader ../../../scripts/node_modules/esbuild-register/loader.js -r ../../../scripts/node_modules/esbuild-register/register.js ../../../scripts/prepare/bundle.ts"
},
"dependencies": {
+ "@storybook/csf": "^0.1.5",
"ts-dedent": "^2.0.0"
},
"devDependencies": {
diff --git a/code/lib/core-events/src/data/argtypes-info.ts b/code/lib/core-events/src/data/argtypes-info.ts
new file mode 100644
index 000000000000..c993c684768c
--- /dev/null
+++ b/code/lib/core-events/src/data/argtypes-info.ts
@@ -0,0 +1,9 @@
+import type { ArgTypes } from '@storybook/csf';
+
+export interface ArgTypesRequestPayload {
+ storyId: string;
+}
+
+export interface ArgTypesResponsePayload {
+ argTypes: ArgTypes;
+}
diff --git a/code/lib/core-events/src/data/create-new-story.ts b/code/lib/core-events/src/data/create-new-story.ts
new file mode 100644
index 000000000000..8727d99f0c12
--- /dev/null
+++ b/code/lib/core-events/src/data/create-new-story.ts
@@ -0,0 +1,24 @@
+export interface CreateNewStoryRequestPayload {
+ // The filepath of the component for which the Story should be generated for (relative to the project root)
+ componentFilePath: string;
+ // The name of the exported component
+ componentExportName: string;
+ // is default export
+ componentIsDefaultExport: boolean;
+ // The amount of exports in the file
+ componentExportCount: number;
+}
+
+export interface CreateNewStoryResponsePayload {
+ // The story id
+ storyId: string;
+ // The story file path relative to the cwd
+ storyFilePath: string;
+ // The name of the story export in the file
+ exportedStoryName: string;
+}
+
+export type CreateNewStoryErrorPayload = {
+ type: 'STORY_FILE_EXISTS';
+ kind: string;
+};
diff --git a/code/lib/core-events/src/data/file-component-search.ts b/code/lib/core-events/src/data/file-component-search.ts
new file mode 100644
index 000000000000..000ae3e3d4c9
--- /dev/null
+++ b/code/lib/core-events/src/data/file-component-search.ts
@@ -0,0 +1,17 @@
+export interface FileComponentSearchRequestPayload {}
+
+export interface FileComponentSearchResponsePayload {
+ files: Array<{
+ // The filepath relative to the project root
+ filepath: string;
+ // Whether a corresponding story file exists
+ storyFileExists: boolean;
+ // A list of exported components
+ exportedComponents: Array<{
+ // the name of the exported component
+ name: string;
+ // True, if the exported component is a default export
+ default: boolean;
+ }> | null;
+ }> | null;
+}
diff --git a/code/lib/core-events/src/data/request-response.ts b/code/lib/core-events/src/data/request-response.ts
new file mode 100644
index 000000000000..d2bd4b5510b8
--- /dev/null
+++ b/code/lib/core-events/src/data/request-response.ts
@@ -0,0 +1,8 @@
+export type RequestData
= {
+ id: string;
+ payload: Payload;
+};
+
+export type ResponseData | void = void> =
+ | { id: string; success: true; error: null; payload: Payload }
+ | { id: string; success: false; error: string; payload?: ErrorPayload };
diff --git a/code/lib/core-events/src/data/save-story.ts b/code/lib/core-events/src/data/save-story.ts
new file mode 100644
index 000000000000..8dd4c009f044
--- /dev/null
+++ b/code/lib/core-events/src/data/save-story.ts
@@ -0,0 +1,14 @@
+export interface SaveStoryRequestPayload {
+ args: string | undefined;
+ csfId: string;
+ importPath: string;
+ name?: string;
+}
+
+export interface SaveStoryResponsePayload {
+ csfId: string;
+ newStoryId?: string;
+ newStoryName?: string;
+ sourceFileName?: string;
+ sourceStoryName?: string;
+}
diff --git a/code/lib/core-events/src/errors/preview-errors.test.ts b/code/lib/core-events/src/errors/preview-errors.test.ts
new file mode 100644
index 000000000000..fec373b143ee
--- /dev/null
+++ b/code/lib/core-events/src/errors/preview-errors.test.ts
@@ -0,0 +1,29 @@
+import { describe, it, expect } from 'vitest';
+import { UnknownArgTypesError } from './preview-errors';
+
+describe('UnknownFlowArgTypesError', () => {
+ it('should correctly handle error with convertSig', () => {
+ const type = {
+ name: 'signature',
+ raw: "SomeType['someProperty']",
+ };
+
+ const typeError = new UnknownArgTypesError({ type, language: 'Typescript' });
+ expect(typeError.message).toMatchInlineSnapshot(`
+ "There was a failure when generating detailed ArgTypes in Typescript for:
+
+ {
+ "name": "signature",
+ "raw": "SomeType['someProperty']"
+ }
+
+ Storybook will fall back to use a generic type description instead.
+
+ This type is either not supported or it is a bug in the docgen generation in Storybook.
+ If you think this is a bug, please detail it as much as possible in the Github issue.
+
+ More info: https://github.com/storybookjs/storybook/issues/26606
+ "
+ `);
+ });
+});
diff --git a/code/lib/core-events/src/errors/preview-errors.ts b/code/lib/core-events/src/errors/preview-errors.ts
index fb33c42688b3..ca3cdc656901 100644
--- a/code/lib/core-events/src/errors/preview-errors.ts
+++ b/code/lib/core-events/src/errors/preview-errors.ts
@@ -11,6 +11,7 @@ import { StorybookError } from './storybook-error';
* to prevent manager and preview errors from having the same category and error code.
*/
export enum Category {
+ DOCS_TOOLS = 'DOCS-TOOLS',
PREVIEW_CLIENT_LOGGER = 'PREVIEW_CLIENT-LOGGER',
PREVIEW_CHANNELS = 'PREVIEW_CHANNELS',
PREVIEW_CORE_EVENTS = 'PREVIEW_CORE-EVENTS',
@@ -27,6 +28,7 @@ export enum Category {
RENDERER_VUE = 'RENDERER_VUE',
RENDERER_VUE3 = 'RENDERER_VUE3',
RENDERER_WEB_COMPONENTS = 'RENDERER_WEB-COMPONENTS',
+ FRAMEWORK_NEXTJS = 'FRAMEWORK_NEXTJS',
}
export class MissingStoryAfterHmrError extends StorybookError {
@@ -235,3 +237,60 @@ export class StoryStoreAccessedBeforeInitializationError extends StorybookError
remove access to the store entirely`;
}
}
+
+export class NextJsSharpError extends StorybookError {
+ readonly category = Category.FRAMEWORK_NEXTJS;
+
+ readonly code = 1;
+
+ readonly documentation = 'https://storybook.js.org/docs/get-started/nextjs#faq';
+
+ template() {
+ return dedent`
+ You are importing avif images, but you don't have sharp installed.
+
+ You have to install sharp in order to use image optimization features in Next.js.
+ `;
+ }
+}
+
+export class NextjsRouterMocksNotAvailable extends StorybookError {
+ readonly category = Category.FRAMEWORK_NEXTJS;
+
+ readonly code = 2;
+
+ constructor(public data: { importType: string }) {
+ super();
+ }
+
+ template() {
+ return dedent`
+ Tried to access router mocks from "${this.data.importType}" but they were not created yet. You might be running code in an unsupported environment.
+ `;
+ }
+}
+
+export class UnknownArgTypesError extends StorybookError {
+ readonly category = Category.DOCS_TOOLS;
+
+ readonly code = 1;
+
+ readonly documentation = 'https://github.com/storybookjs/storybook/issues/26606';
+
+ constructor(public data: { type: object; language: string }) {
+ super();
+ }
+
+ template() {
+ return dedent`There was a failure when generating detailed ArgTypes in ${
+ this.data.language
+ } for:
+
+ ${JSON.stringify(this.data.type, null, 2)}
+
+ Storybook will fall back to use a generic type description instead.
+
+ This type is either not supported or it is a bug in the docgen generation in Storybook.
+ If you think this is a bug, please detail it as much as possible in the Github issue.`;
+ }
+}
diff --git a/code/lib/core-events/src/index.ts b/code/lib/core-events/src/index.ts
index 82978a994d07..8ce72f12a8dd 100644
--- a/code/lib/core-events/src/index.ts
+++ b/code/lib/core-events/src/index.ts
@@ -73,8 +73,15 @@ enum events {
SET_WHATS_NEW_CACHE = 'setWhatsNewCache',
TOGGLE_WHATS_NEW_NOTIFICATIONS = 'toggleWhatsNewNotifications',
TELEMETRY_ERROR = 'telemetryError',
- FILE_COMPONENT_SEARCH = 'fileComponentSearch',
- FILE_COMPONENT_SEARCH_RESULT = 'fileComponentSearchResult',
+
+ FILE_COMPONENT_SEARCH_REQUEST = 'fileComponentSearchRequest',
+ FILE_COMPONENT_SEARCH_RESPONSE = 'fileComponentSearchResponse',
+ SAVE_STORY_REQUEST = 'saveStoryRequest',
+ SAVE_STORY_RESPONSE = 'saveStoryResponse',
+ ARGTYPES_INFO_REQUEST = 'argtypesInfoRequest',
+ ARGTYPES_INFO_RESPONSE = 'argtypesInfoResponse',
+ CREATE_NEW_STORYFILE_REQUEST = 'createNewStoryfileRequest',
+ CREATE_NEW_STORYFILE_RESPONSE = 'createNewStoryfileResponse',
}
// Enables: `import Events from ...`
@@ -86,11 +93,13 @@ export const {
CHANNEL_WS_DISCONNECT,
CHANNEL_CREATED,
CONFIG_ERROR,
+ CREATE_NEW_STORYFILE_REQUEST,
+ CREATE_NEW_STORYFILE_RESPONSE,
CURRENT_STORY_WAS_SET,
DOCS_PREPARED,
DOCS_RENDERED,
- FILE_COMPONENT_SEARCH,
- FILE_COMPONENT_SEARCH_RESULT,
+ FILE_COMPONENT_SEARCH_REQUEST,
+ FILE_COMPONENT_SEARCH_RESPONSE,
FORCE_RE_RENDER,
FORCE_REMOUNT,
GLOBALS_UPDATED,
@@ -131,8 +140,18 @@ export const {
SET_WHATS_NEW_CACHE,
TOGGLE_WHATS_NEW_NOTIFICATIONS,
TELEMETRY_ERROR,
+ SAVE_STORY_REQUEST,
+ SAVE_STORY_RESPONSE,
+ ARGTYPES_INFO_REQUEST,
+ ARGTYPES_INFO_RESPONSE,
} = events;
+export * from './data/create-new-story';
+export * from './data/file-component-search';
+export * from './data/argtypes-info';
+export * from './data/request-response';
+export * from './data/save-story';
+
export interface WhatsNewCache {
lastDismissedPost?: string;
lastReadPost?: string;
diff --git a/code/lib/core-server/package.json b/code/lib/core-server/package.json
index 9edbf6ea41e4..baaabbbfb69b 100644
--- a/code/lib/core-server/package.json
+++ b/code/lib/core-server/package.json
@@ -56,13 +56,14 @@
},
"dependencies": {
"@aw-web-design/x-default-browser": "1.4.126",
- "@babel/core": "^7.23.9",
+ "@babel/core": "^7.24.4",
+ "@babel/parser": "^7.24.4",
"@discoveryjs/json-ext": "^0.5.3",
"@storybook/builder-manager": "workspace:*",
"@storybook/channels": "workspace:*",
"@storybook/core-common": "workspace:*",
"@storybook/core-events": "workspace:*",
- "@storybook/csf": "^0.1.2",
+ "@storybook/csf": "^0.1.6",
"@storybook/csf-tools": "workspace:*",
"@storybook/docs-mdx": "3.0.0",
"@storybook/global": "^5.0.0",
@@ -73,16 +74,16 @@
"@storybook/telemetry": "workspace:*",
"@storybook/types": "workspace:*",
"@types/detect-port": "^1.3.0",
+ "@types/diff": "^5.0.9",
"@types/node": "^18.0.0",
"@types/pretty-hrtime": "^1.0.0",
"@types/semver": "^7.3.4",
"better-opn": "^3.0.2",
"chalk": "^4.1.0",
- "cjs-module-lexer": "^1.2.3",
"cli-table3": "^0.6.1",
"compression": "^1.7.4",
"detect-port": "^1.3.0",
- "es-module-lexer": "^1.5.0",
+ "diff": "^5.2.0",
"express": "^4.17.3",
"fs-extra": "^11.1.0",
"globby": "^14.0.1",
@@ -108,6 +109,7 @@
"@types/node-fetch": "^2.5.7",
"@types/ws": "^8",
"boxen": "^7.1.1",
+ "camelcase": "^8.0.0",
"node-fetch": "^3.3.1",
"slash": "^5.0.0",
"typescript": "^5.3.2"
diff --git a/code/lib/core-server/src/presets/common-preset.ts b/code/lib/core-server/src/presets/common-preset.ts
index fbc39465aad4..0089a4a74fa7 100644
--- a/code/lib/core-server/src/presets/common-preset.ts
+++ b/code/lib/core-server/src/presets/common-preset.ts
@@ -1,8 +1,7 @@
-import fs, { pathExists, readFile } from 'fs-extra';
+import { pathExists, readFile } from 'fs-extra';
import { logger } from '@storybook/node-logger';
import { telemetry } from '@storybook/telemetry';
import {
- findConfigFile,
getDirectoryFromWorkingDir,
getPreviewBodyTemplate,
getPreviewHeadTemplate,
@@ -17,24 +16,16 @@ import type {
PresetPropertyFn,
PresetProperty,
} from '@storybook/types';
-import { printConfig, readConfig, readCsf } from '@storybook/csf-tools';
+import { readCsf } from '@storybook/csf-tools';
import { join, dirname, isAbsolute } from 'path';
import { dedent } from 'ts-dedent';
-import fetch from 'node-fetch';
import type { Channel } from '@storybook/channels';
-import type { WhatsNewCache, WhatsNewData } from '@storybook/core-events';
-import {
- REQUEST_WHATS_NEW_DATA,
- RESULT_WHATS_NEW_DATA,
- TELEMETRY_ERROR,
- SET_WHATS_NEW_CACHE,
- TOGGLE_WHATS_NEW_NOTIFICATIONS,
-} from '@storybook/core-events';
-import invariant from 'tiny-invariant';
import { parseStaticDir } from '../utils/server-statics';
import { defaultStaticDirs } from '../utils/constants';
-import { sendTelemetryError } from '../withTelemetry';
+import { initializeWhatsNew, type OptionsWithRequiredCache } from '../utils/whats-new';
+import { initializeSaveStory } from '../utils/save-story/save-story';
import { initFileSearchChannel } from '../server-channel/file-search-channel';
+import { initCreateNewStoryChannel } from '../server-channel/create-new-story-channel';
const interpolate = (string: string, data: Record = {}) =>
Object.entries(data).reduce((acc, [k, v]) => acc.replace(new RegExp(`%${k}%`, 'g'), v), string);
@@ -97,7 +88,7 @@ export const favicon = async (
if (flatlist.length > 1) {
logger.warn(dedent`
Looks like multiple favicons were detected. Using the first one.
-
+
${flatlist.join(', ')}
`);
}
@@ -240,20 +231,6 @@ export const managerHead = async (_: any, options: Options) => {
return '';
};
-const WHATS_NEW_CACHE = 'whats-new-cache';
-const WHATS_NEW_URL = 'https://storybook.js.org/whats-new/v1';
-
-// Grabbed from the implementation: https://github.com/storybookjs/dx-functions/blob/main/netlify/functions/whats-new.ts
-type WhatsNewResponse = {
- title: string;
- url: string;
- blogUrl?: string;
- publishedAt: string;
- excerpt: string;
-};
-
-type OptionsWithRequiredCache = Exclude & Required>;
-
// eslint-disable-next-line @typescript-eslint/naming-convention
export const experimental_serverChannel = async (
channel: Channel,
@@ -261,87 +238,11 @@ export const experimental_serverChannel = async (
) => {
const coreOptions = await options.presets.apply('core');
- channel.on(SET_WHATS_NEW_CACHE, async (data: WhatsNewCache) => {
- const cache: WhatsNewCache = await options.cache.get(WHATS_NEW_CACHE).catch((e) => {
- logger.verbose(e);
- return {};
- });
- await options.cache.set(WHATS_NEW_CACHE, { ...cache, ...data });
- });
-
- channel.on(REQUEST_WHATS_NEW_DATA, async () => {
- try {
- const post = (await fetch(WHATS_NEW_URL).then(async (response) => {
- if (response.ok) return response.json();
- // eslint-disable-next-line @typescript-eslint/no-throw-literal
- throw response;
- })) as WhatsNewResponse;
-
- const configFileName = findConfigFile('main', options.configDir);
- if (!configFileName)
- throw new Error(`unable to find storybook main file in ${options.configDir}`);
- const main = await readConfig(configFileName);
- const disableWhatsNewNotifications = main.getFieldValue([
- 'core',
- 'disableWhatsNewNotifications',
- ]);
-
- const cache: WhatsNewCache = (await options.cache.get(WHATS_NEW_CACHE)) ?? {};
- const data = {
- ...post,
- status: 'SUCCESS',
- postIsRead: post.url === cache.lastReadPost,
- showNotification: post.url !== cache.lastDismissedPost && post.url !== cache.lastReadPost,
- disableWhatsNewNotifications,
- } satisfies WhatsNewData;
- channel.emit(RESULT_WHATS_NEW_DATA, { data });
- } catch (e) {
- logger.verbose(e instanceof Error ? e.message : String(e));
- channel.emit(RESULT_WHATS_NEW_DATA, {
- data: { status: 'ERROR' } satisfies WhatsNewData,
- });
- }
- });
-
- channel.on(
- TOGGLE_WHATS_NEW_NOTIFICATIONS,
- async ({ disableWhatsNewNotifications }: { disableWhatsNewNotifications: boolean }) => {
- const isTelemetryEnabled = coreOptions.disableTelemetry !== true;
- try {
- const mainPath = findConfigFile('main', options.configDir);
- invariant(mainPath, `unable to find storybook main file in ${options.configDir}`);
- const main = await readConfig(mainPath);
- main.setFieldValue(['core', 'disableWhatsNewNotifications'], disableWhatsNewNotifications);
- await fs.writeFile(mainPath, printConfig(main).code);
- if (isTelemetryEnabled) {
- await telemetry('core-config', { disableWhatsNewNotifications });
- }
- } catch (error) {
- invariant(error instanceof Error);
- if (isTelemetryEnabled) {
- await sendTelemetryError(error, 'core-config', {
- cliOptions: options,
- presetOptions: { ...options, corePresets: [], overridePresets: [] },
- skipPrompt: true,
- });
- }
- }
- }
- );
-
- channel.on(TELEMETRY_ERROR, async (error) => {
- const isTelemetryEnabled = coreOptions.disableTelemetry !== true;
-
- if (isTelemetryEnabled) {
- await sendTelemetryError(error, 'browser', {
- cliOptions: options,
- presetOptions: { ...options, corePresets: [], overridePresets: [] },
- skipPrompt: true,
- });
- }
- });
+ initializeWhatsNew(channel, options, coreOptions);
+ initializeSaveStory(channel, options, coreOptions);
- initFileSearchChannel(channel, options);
+ initFileSearchChannel(channel, options, coreOptions);
+ initCreateNewStoryChannel(channel, options, coreOptions);
return channel;
};
diff --git a/code/lib/core-server/src/server-channel/create-new-story-channel.test.ts b/code/lib/core-server/src/server-channel/create-new-story-channel.test.ts
new file mode 100644
index 000000000000..dc3763cb2d6b
--- /dev/null
+++ b/code/lib/core-server/src/server-channel/create-new-story-channel.test.ts
@@ -0,0 +1,142 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { initCreateNewStoryChannel } from './create-new-story-channel';
+import path from 'path';
+import type { ChannelTransport } from '@storybook/channels';
+import { Channel } from '@storybook/channels';
+import type { CreateNewStoryRequestPayload, RequestData } from '@storybook/core-events';
+import {
+ CREATE_NEW_STORYFILE_REQUEST,
+ CREATE_NEW_STORYFILE_RESPONSE,
+} from '@storybook/core-events';
+
+vi.mock('@storybook/core-common', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ getProjectRoot: vi.fn().mockReturnValue(process.cwd()),
+ };
+});
+
+const mockFs = vi.hoisted(() => {
+ return {
+ writeFile: vi.fn(),
+ };
+});
+
+vi.mock('node:fs/promises', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ default: {
+ ...actual,
+ writeFile: mockFs.writeFile,
+ },
+ };
+});
+
+describe('createNewStoryChannel', () => {
+ const transport = { setHandler: vi.fn(), send: vi.fn() } satisfies ChannelTransport;
+ const mockChannel = new Channel({ transport });
+ const createNewStoryFileEventListener = vi.fn();
+
+ beforeEach(() => {
+ transport.setHandler.mockClear();
+ transport.send.mockClear();
+ createNewStoryFileEventListener.mockClear();
+ });
+
+ describe('initCreateNewStoryChannel', () => {
+ it('should emit an event with a story id', async () => {
+ mockChannel.addListener(CREATE_NEW_STORYFILE_RESPONSE, createNewStoryFileEventListener);
+ const cwd = process.cwd();
+
+ initCreateNewStoryChannel(
+ mockChannel,
+ {
+ configDir: path.join(cwd, '.storybook'),
+ presets: {
+ apply: (val: string) => {
+ if (val === 'framework') {
+ return Promise.resolve('@storybook/nextjs');
+ }
+ if (val === 'stories') {
+ return Promise.resolve(['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)']);
+ }
+ },
+ },
+ } as any,
+ { disableTelemetry: true }
+ );
+
+ mockChannel.emit(CREATE_NEW_STORYFILE_REQUEST, {
+ id: 'components-page--default',
+ payload: {
+ componentFilePath: 'src/components/Page.jsx',
+ componentExportName: 'Page',
+ componentIsDefaultExport: true,
+ },
+ });
+
+ await vi.waitFor(() => {
+ expect(createNewStoryFileEventListener).toHaveBeenCalled();
+ });
+
+ expect(createNewStoryFileEventListener).toHaveBeenCalledWith({
+ error: null,
+ id: 'components-page--default',
+ payload: {
+ storyId: 'components-page--default',
+ storyFilePath: path.join('src', 'components', 'Page.stories.jsx'),
+ exportedStoryName: 'Default',
+ },
+ success: true,
+ });
+ });
+
+ it('should emit an error event if an error occurs', async () => {
+ mockChannel.addListener(CREATE_NEW_STORYFILE_RESPONSE, createNewStoryFileEventListener);
+ const cwd = process.cwd();
+
+ mockFs.writeFile.mockImplementation(() => {
+ throw new Error('Failed to write file');
+ });
+
+ initCreateNewStoryChannel(
+ mockChannel,
+ {
+ configDir: path.join(cwd, '.storybook'),
+ presets: {
+ apply: (val: string) => {
+ if (val === 'framework') {
+ return Promise.resolve('@storybook/nextjs');
+ }
+ if (val === 'stories') {
+ return Promise.resolve(['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)']);
+ }
+ },
+ },
+ } as any,
+ { disableTelemetry: true }
+ );
+
+ mockChannel.emit(CREATE_NEW_STORYFILE_REQUEST, {
+ id: 'components-page--default',
+ payload: {
+ componentFilePath: 'src/components/Page.jsx',
+ componentExportName: 'Page',
+ componentIsDefaultExport: true,
+ componentExportCount: 1,
+ },
+ } satisfies RequestData);
+
+ await vi.waitFor(() => {
+ expect(createNewStoryFileEventListener).toHaveBeenCalled();
+ });
+
+ expect(createNewStoryFileEventListener).toHaveBeenCalledWith({
+ error: 'Failed to write file',
+ id: 'components-page--default',
+ success: false,
+ });
+ });
+ });
+});
diff --git a/code/lib/core-server/src/server-channel/create-new-story-channel.ts b/code/lib/core-server/src/server-channel/create-new-story-channel.ts
new file mode 100644
index 000000000000..18d755afbd44
--- /dev/null
+++ b/code/lib/core-server/src/server-channel/create-new-story-channel.ts
@@ -0,0 +1,99 @@
+import type { CoreConfig, Options } from '@storybook/types';
+import type { Channel } from '@storybook/channels';
+import { telemetry } from '@storybook/telemetry';
+import type {
+ CreateNewStoryErrorPayload,
+ CreateNewStoryRequestPayload,
+ CreateNewStoryResponsePayload,
+ RequestData,
+ ResponseData,
+} from '@storybook/core-events';
+import {
+ CREATE_NEW_STORYFILE_REQUEST,
+ CREATE_NEW_STORYFILE_RESPONSE,
+} from '@storybook/core-events';
+import fs from 'node:fs/promises';
+import { existsSync } from 'node:fs';
+import { getNewStoryFile } from '../utils/get-new-story-file';
+import { getStoryId } from '../utils/get-story-id';
+import path from 'node:path';
+
+export function initCreateNewStoryChannel(
+ channel: Channel,
+ options: Options,
+ coreOptions: CoreConfig
+) {
+ /**
+ * Listens for events to create a new storyfile
+ */
+ channel.on(
+ CREATE_NEW_STORYFILE_REQUEST,
+ async (data: RequestData) => {
+ try {
+ const { storyFilePath, exportedStoryName, storyFileContent } = await getNewStoryFile(
+ data.payload,
+ options
+ );
+
+ const relativeStoryFilePath = path.relative(process.cwd(), storyFilePath);
+
+ const { storyId, kind } = await getStoryId({ storyFilePath, exportedStoryName }, options);
+
+ if (existsSync(storyFilePath)) {
+ channel.emit(CREATE_NEW_STORYFILE_RESPONSE, {
+ success: false,
+ id: data.id,
+ payload: {
+ type: 'STORY_FILE_EXISTS',
+ kind,
+ },
+ error: `A story file already exists at ${relativeStoryFilePath}`,
+ } satisfies ResponseData);
+
+ if (!coreOptions.disableTelemetry) {
+ telemetry('create-new-story-file', {
+ success: false,
+ error: 'STORY_FILE_EXISTS',
+ });
+ }
+
+ return;
+ }
+
+ await fs.writeFile(storyFilePath, storyFileContent, 'utf-8');
+
+ channel.emit(CREATE_NEW_STORYFILE_RESPONSE, {
+ success: true,
+ id: data.id,
+ payload: {
+ storyId,
+ storyFilePath: path.relative(process.cwd(), storyFilePath),
+ exportedStoryName,
+ },
+ error: null,
+ } satisfies ResponseData);
+
+ if (!coreOptions.disableTelemetry) {
+ telemetry('create-new-story-file', {
+ success: true,
+ });
+ }
+ } catch (e: any) {
+ channel.emit(CREATE_NEW_STORYFILE_RESPONSE, {
+ success: false,
+ id: data.id,
+ error: e?.message,
+ } satisfies ResponseData);
+
+ if (!coreOptions.disableTelemetry) {
+ await telemetry('create-new-story-file', {
+ success: false,
+ error: e,
+ });
+ }
+ }
+ }
+ );
+
+ return channel;
+}
diff --git a/code/lib/core-server/src/server-channel/file-search-channel.test.ts b/code/lib/core-server/src/server-channel/file-search-channel.test.ts
index e967910dd6c7..d12af10ca261 100644
--- a/code/lib/core-server/src/server-channel/file-search-channel.test.ts
+++ b/code/lib/core-server/src/server-channel/file-search-channel.test.ts
@@ -1,6 +1,10 @@
import type { ChannelTransport } from '@storybook/channels';
import { Channel } from '@storybook/channels';
-import { FILE_COMPONENT_SEARCH, FILE_COMPONENT_SEARCH_RESULT } from '@storybook/core-events';
+import type { RequestData, FileComponentSearchRequestPayload } from '@storybook/core-events';
+import {
+ FILE_COMPONENT_SEARCH_RESPONSE,
+ FILE_COMPONENT_SEARCH_REQUEST,
+} from '@storybook/core-events';
import { beforeEach, describe, expect, vi, it } from 'vitest';
import { initFileSearchChannel } from './file-search-channel';
@@ -43,12 +47,15 @@ describe('file-search-channel', () => {
describe('initFileSearchChannel', async () => {
it('should emit search result event with the search result', async () => {
const mockOptions = {};
- const data = { searchQuery: 'commonjs' };
+ const data = { searchQuery: 'es-module' };
- initFileSearchChannel(mockChannel, mockOptions as any);
+ initFileSearchChannel(mockChannel, mockOptions as any, { disableTelemetry: true });
- mockChannel.addListener(FILE_COMPONENT_SEARCH_RESULT, searchResultChannelListener);
- mockChannel.emit(FILE_COMPONENT_SEARCH, data);
+ mockChannel.addListener(FILE_COMPONENT_SEARCH_RESPONSE, searchResultChannelListener);
+ mockChannel.emit(FILE_COMPONENT_SEARCH_REQUEST, {
+ id: data.searchQuery,
+ payload: {},
+ } satisfies RequestData);
mocks.searchFiles.mockImplementation(async (...args) => {
// @ts-expect-error Ignore type issue
@@ -63,45 +70,41 @@ describe('file-search-channel', () => {
);
expect(searchResultChannelListener).toHaveBeenCalledWith({
+ id: data.searchQuery,
error: null,
- result: {
+ payload: {
files: [
{
exportedComponents: [
{
default: false,
- name: './commonjs',
+ name: 'p',
},
- ],
- filepath: 'src/commonjs-module-default.js',
- },
- {
- exportedComponents: [
{
default: false,
- name: 'a',
+ name: 'q',
},
{
default: false,
- name: 'b',
+ name: 'C',
},
{
default: false,
- name: 'c',
+ name: 'externalName',
},
{
default: false,
- name: 'd',
+ name: 'ns',
},
{
- default: false,
- name: 'e',
+ default: true,
+ name: 'default',
},
],
- filepath: 'src/commonjs-module.js',
+ filepath: 'src/es-module.js',
+ storyFileExists: true,
},
],
- searchQuery: 'commonjs',
},
success: true,
});
@@ -111,10 +114,13 @@ describe('file-search-channel', () => {
const mockOptions = {};
const data = { searchQuery: 'no-file-for-search-query' };
- initFileSearchChannel(mockChannel, mockOptions as any);
+ initFileSearchChannel(mockChannel, mockOptions as any, { disableTelemetry: true });
- mockChannel.addListener(FILE_COMPONENT_SEARCH_RESULT, searchResultChannelListener);
- mockChannel.emit(FILE_COMPONENT_SEARCH, data);
+ mockChannel.addListener(FILE_COMPONENT_SEARCH_RESPONSE, searchResultChannelListener);
+ mockChannel.emit(FILE_COMPONENT_SEARCH_REQUEST, {
+ id: data.searchQuery,
+ payload: {},
+ } satisfies RequestData);
mocks.searchFiles.mockImplementation(async (...args) => {
// @ts-expect-error Ignore type issue
@@ -129,10 +135,10 @@ describe('file-search-channel', () => {
);
expect(searchResultChannelListener).toHaveBeenCalledWith({
+ id: data.searchQuery,
error: null,
- result: {
+ payload: {
files: [],
- searchQuery: 'no-file-for-search-query',
},
success: true,
});
@@ -142,11 +148,14 @@ describe('file-search-channel', () => {
const mockOptions = {};
const data = { searchQuery: 'commonjs' };
- initFileSearchChannel(mockChannel, mockOptions as any);
+ initFileSearchChannel(mockChannel, mockOptions as any, { disableTelemetry: true });
- mockChannel.addListener(FILE_COMPONENT_SEARCH_RESULT, searchResultChannelListener);
+ mockChannel.addListener(FILE_COMPONENT_SEARCH_RESPONSE, searchResultChannelListener);
- mockChannel.emit(FILE_COMPONENT_SEARCH, data);
+ mockChannel.emit(FILE_COMPONENT_SEARCH_REQUEST, {
+ id: data.searchQuery,
+ payload: {},
+ } satisfies RequestData);
mocks.searchFiles.mockRejectedValue(new Error('ENOENT: no such file or directory'));
@@ -155,9 +164,9 @@ describe('file-search-channel', () => {
});
expect(searchResultChannelListener).toHaveBeenCalledWith({
+ id: data.searchQuery,
error:
'An error occurred while searching for components in the project.\nENOENT: no such file or directory',
- result: null,
success: false,
});
});
diff --git a/code/lib/core-server/src/server-channel/file-search-channel.ts b/code/lib/core-server/src/server-channel/file-search-channel.ts
index 3f2884867447..58e94fdfcc77 100644
--- a/code/lib/core-server/src/server-channel/file-search-channel.ts
+++ b/code/lib/core-server/src/server-channel/file-search-channel.ts
@@ -1,4 +1,4 @@
-import type { Options, SupportedRenderers } from '@storybook/types';
+import type { CoreConfig, Options, SupportedRenderers } from '@storybook/types';
import type { Channel } from '@storybook/channels';
import {
extractProperRendererNameFromFramework,
@@ -10,97 +10,121 @@ import fs from 'fs/promises';
import { getParser } from '../utils/parser';
import { searchFiles } from '../utils/search-files';
-import { FILE_COMPONENT_SEARCH, FILE_COMPONENT_SEARCH_RESULT } from '@storybook/core-events';
-
-interface Data {
- // A regular string or a glob pattern
- searchQuery?: string;
-}
-
-interface SearchResult {
- success: true | false;
- result: null | {
- searchQuery: string;
- files: Array<{
- // The filepath relative to the project root
- filepath: string;
- // The search query - Helps to identify the event on the frontend
- searchQuery: string;
- // A list of exported components
- exportedComponents: Array<{
- // the name of the exported component
- name: string;
- // True, if the exported component is a default export
- default: boolean;
- }>;
- }> | null;
- };
- error: null | string;
-}
+import type {
+ FileComponentSearchRequestPayload,
+ FileComponentSearchResponsePayload,
+ RequestData,
+ ResponseData,
+} from '@storybook/core-events';
+import {
+ FILE_COMPONENT_SEARCH_REQUEST,
+ FILE_COMPONENT_SEARCH_RESPONSE,
+} from '@storybook/core-events';
+import { doesStoryFileExist, getStoryMetadata } from '../utils/get-new-story-file';
+import { telemetry } from '@storybook/telemetry';
-export function initFileSearchChannel(channel: Channel, options: Options) {
+export async function initFileSearchChannel(
+ channel: Channel,
+ options: Options,
+ coreOptions: CoreConfig
+) {
/**
* Listens for a search query event and searches for files in the project
*/
- channel.on(FILE_COMPONENT_SEARCH, async (data: Data) => {
- try {
- const searchQuery = data?.searchQuery;
+ channel.on(
+ FILE_COMPONENT_SEARCH_REQUEST,
+ async (data: RequestData) => {
+ const searchQuery = data.id;
+ try {
+ if (!searchQuery) {
+ return;
+ }
- if (!searchQuery) {
- return;
- }
+ const frameworkName = await getFrameworkName(options);
- const frameworkName = await getFrameworkName(options);
+ const rendererName = (await extractProperRendererNameFromFramework(
+ frameworkName
+ )) as SupportedRenderers;
- const rendererName = (await extractProperRendererNameFromFramework(
- frameworkName
- )) as SupportedRenderers;
+ const projectRoot = getProjectRoot();
- const projectRoot = getProjectRoot();
+ const files = await searchFiles({
+ searchQuery,
+ cwd: projectRoot,
+ });
- const files = await searchFiles({
- searchQuery,
- cwd: projectRoot,
- });
+ const entries = files.map(async (file) => {
+ const parser = getParser(rendererName);
- const entries = files.map(async (file) => {
- const parser = getParser(rendererName);
+ try {
+ const content = await fs.readFile(path.join(projectRoot, file), 'utf-8');
+ const { storyFileName } = getStoryMetadata(path.join(projectRoot, file));
+ const dirname = path.dirname(file);
- try {
- const content = await fs.readFile(path.join(projectRoot, file), 'utf-8');
- const info = await parser.parse(content);
+ const storyFileExists = doesStoryFileExist(
+ path.join(projectRoot, dirname),
+ storyFileName
+ );
- return {
- filepath: file,
- exportedComponents: info.exports,
- };
- } catch (e) {
- return {
- filepath: file,
- exportedComponents: null,
- };
+ const info = await parser.parse(content);
+
+ return {
+ filepath: file,
+ exportedComponents: info.exports,
+ storyFileExists,
+ };
+ } catch (e) {
+ if (!coreOptions.disableTelemetry) {
+ telemetry('create-new-story-file-search', {
+ success: false,
+ error: `Could not parse file: ${e}`,
+ });
+ }
+
+ return {
+ filepath: file,
+ storyFileExists: false,
+ exportedComponents: null,
+ };
+ }
+ });
+
+ if (!coreOptions.disableTelemetry) {
+ telemetry('create-new-story-file-search', {
+ success: true,
+ payload: {
+ fileCount: entries.length,
+ },
+ });
}
- });
- channel.emit(FILE_COMPONENT_SEARCH_RESULT, {
- success: true,
- result: {
- searchQuery,
- files: await Promise.all(entries),
- },
- error: null,
- } as SearchResult);
- } catch (e: any) {
- /**
- * Emits the search result event with an error message
- */
- channel.emit(FILE_COMPONENT_SEARCH_RESULT, {
- success: false,
- result: null,
- error: `An error occurred while searching for components in the project.\n${e?.message}`,
- } as SearchResult);
+ channel.emit(FILE_COMPONENT_SEARCH_RESPONSE, {
+ success: true,
+ id: searchQuery,
+ payload: {
+ files: await Promise.all(entries),
+ },
+ error: null,
+ } satisfies ResponseData);
+ } catch (e: any) {
+ /**
+ * Emits the search result event with an error message
+ */
+ channel.emit(FILE_COMPONENT_SEARCH_RESPONSE, {
+ success: false,
+ id: searchQuery ?? '',
+ error: `An error occurred while searching for components in the project.\n${e?.message}`,
+ } satisfies ResponseData);
+
+ if (!coreOptions.disableTelemetry) {
+ telemetry('create-new-story-file-search', {
+ success: false,
+ error: `An error occured while searching for components: ${e}`,
+ });
+ }
+ }
}
- });
+ );
return channel;
}
diff --git a/code/lib/core-server/src/utils/StoryIndexGenerator.ts b/code/lib/core-server/src/utils/StoryIndexGenerator.ts
index 09a5c95fff05..9f2c147c9a1f 100644
--- a/code/lib/core-server/src/utils/StoryIndexGenerator.ts
+++ b/code/lib/core-server/src/utils/StoryIndexGenerator.ts
@@ -509,7 +509,7 @@ export class StoryIndexGenerator {
// Otherwise the existing entry is created by `autodocs=true` which allowed to be overridden.
} else {
// If both entries are templates (e.g. you have two CSF files with the same title), then
- // we need to merge the entries. We'll use the the first one's name and importPath,
+ // we need to merge the entries. We'll use the first one's name and importPath,
// but ensure we include both as storiesImports so they are both loaded before rendering
// the story (for the block & friends)
return {
diff --git a/code/lib/core-server/src/utils/__search-files-tests__/src/es-module.stories.js b/code/lib/core-server/src/utils/__search-files-tests__/src/es-module.stories.js
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/code/lib/core-server/src/utils/get-component-variable-name.test.ts b/code/lib/core-server/src/utils/get-component-variable-name.test.ts
new file mode 100644
index 000000000000..07b2fb5599aa
--- /dev/null
+++ b/code/lib/core-server/src/utils/get-component-variable-name.test.ts
@@ -0,0 +1,11 @@
+import { describe, expect, it } from 'vitest';
+import { getComponentVariableName } from './get-component-variable-name';
+
+describe('get-variable-name', () => {
+ it('should return a valid variable name for a given string', async () => {
+ await expect(getComponentVariableName('foo-bar')).resolves.toBe('FooBar');
+ await expect(getComponentVariableName('foo bar')).resolves.toBe('FooBar');
+ await expect(getComponentVariableName('0-foo-bar')).resolves.toBe('FooBar');
+ await expect(getComponentVariableName('*Foo-bar-$')).resolves.toBe('FooBar$');
+ });
+});
diff --git a/code/lib/core-server/src/utils/get-component-variable-name.ts b/code/lib/core-server/src/utils/get-component-variable-name.ts
new file mode 100644
index 000000000000..bc29ff9e51d6
--- /dev/null
+++ b/code/lib/core-server/src/utils/get-component-variable-name.ts
@@ -0,0 +1,12 @@
+/**
+ * Get a valid variable name for a component.
+ *
+ * @param name The name of the component.
+ * @returns A valid variable name.
+ */
+export const getComponentVariableName = async (name: string) => {
+ const camelCase = await import('camelcase');
+ const camelCased = camelCase.default(name.replace(/^[^a-zA-Z_$]*/, ''), { pascalCase: true });
+ const sanitized = camelCased.replace(/[^a-zA-Z_$]+/, '');
+ return sanitized;
+};
diff --git a/code/lib/core-server/src/utils/get-new-story-file.test.ts b/code/lib/core-server/src/utils/get-new-story-file.test.ts
new file mode 100644
index 000000000000..91b06d9027b0
--- /dev/null
+++ b/code/lib/core-server/src/utils/get-new-story-file.test.ts
@@ -0,0 +1,85 @@
+import { describe, expect, it, vi } from 'vitest';
+import { getNewStoryFile } from './get-new-story-file';
+import path from 'path';
+
+vi.mock('@storybook/core-common', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ getProjectRoot: vi.fn().mockReturnValue(require('path').join(__dirname)),
+ };
+});
+
+describe('get-new-story-file', () => {
+ it('should create a new story file (TypeScript)', async () => {
+ const { exportedStoryName, storyFileContent, storyFilePath } = await getNewStoryFile(
+ {
+ componentFilePath: 'src/components/Page.tsx',
+ componentExportName: 'Page',
+ componentIsDefaultExport: false,
+ componentExportCount: 1,
+ },
+ {
+ presets: {
+ apply: (val: string) => {
+ if (val === 'framework') {
+ return Promise.resolve('@storybook/nextjs');
+ }
+ },
+ },
+ } as any
+ );
+
+ expect(exportedStoryName).toBe('Default');
+ expect(storyFileContent).toMatchInlineSnapshot(`
+ "import type { Meta, StoryObj } from '@storybook/react';
+
+ import { Page } from './Page';
+
+ const meta = {
+ component: Page,
+ } satisfies Meta;
+
+ export default meta;
+
+ type Story = StoryObj;
+
+ export const Default: Story = {};"
+ `);
+ expect(storyFilePath).toBe(path.join(__dirname, 'src', 'components', 'Page.stories.tsx'));
+ });
+
+ it('should create a new story file (JavaScript)', async () => {
+ const { exportedStoryName, storyFileContent, storyFilePath } = await getNewStoryFile(
+ {
+ componentFilePath: 'src/components/Page.jsx',
+ componentExportName: 'Page',
+ componentIsDefaultExport: true,
+ componentExportCount: 1,
+ },
+ {
+ presets: {
+ apply: (val: string) => {
+ if (val === 'framework') {
+ return Promise.resolve('@storybook/nextjs');
+ }
+ },
+ },
+ } as any
+ );
+
+ expect(exportedStoryName).toBe('Default');
+ expect(storyFileContent).toMatchInlineSnapshot(`
+ "import Page from './Page';
+
+ const meta = {
+ component: Page,
+ };
+
+ export default meta;
+
+ export const Default = {};"
+ `);
+ expect(storyFilePath).toBe(path.join(__dirname, 'src', 'components', 'Page.stories.jsx'));
+ });
+});
diff --git a/code/lib/core-server/src/utils/get-new-story-file.ts b/code/lib/core-server/src/utils/get-new-story-file.ts
new file mode 100644
index 000000000000..32c50c2e5009
--- /dev/null
+++ b/code/lib/core-server/src/utils/get-new-story-file.ts
@@ -0,0 +1,86 @@
+import type { Options } from '@storybook/types';
+import {
+ extractProperRendererNameFromFramework,
+ getFrameworkName,
+ getProjectRoot,
+ rendererPackages,
+} from '@storybook/core-common';
+import path from 'node:path';
+import fs from 'node:fs';
+import { getTypeScriptTemplateForNewStoryFile } from './new-story-templates/typescript';
+import { getJavaScriptTemplateForNewStoryFile } from './new-story-templates/javascript';
+import type { CreateNewStoryRequestPayload } from '@storybook/core-events';
+
+export async function getNewStoryFile(
+ {
+ componentFilePath,
+ componentExportName,
+ componentIsDefaultExport,
+ componentExportCount,
+ }: CreateNewStoryRequestPayload,
+ options: Options
+) {
+ const cwd = getProjectRoot();
+
+ const frameworkPackageName = await getFrameworkName(options);
+ const rendererName = await extractProperRendererNameFromFramework(frameworkPackageName);
+ const rendererPackage = Object.entries(rendererPackages).find(
+ ([, value]) => value === rendererName
+ )?.[0];
+
+ const basename = path.basename(componentFilePath);
+ const extension = path.extname(componentFilePath);
+ const basenameWithoutExtension = basename.replace(extension, '');
+ const dirname = path.dirname(componentFilePath);
+
+ const { storyFileName, isTypescript, storyFileExtension } = getStoryMetadata(componentFilePath);
+ const storyFileNameWithExtension = `${storyFileName}.${storyFileExtension}`;
+ const alternativeStoryFileNameWithExtension = `${basenameWithoutExtension}.${componentExportName}.stories.${storyFileExtension}`;
+
+ const exportedStoryName = 'Default';
+
+ const storyFileContent =
+ isTypescript && rendererPackage
+ ? await getTypeScriptTemplateForNewStoryFile({
+ basenameWithoutExtension,
+ componentExportName,
+ componentIsDefaultExport,
+ rendererPackage,
+ exportedStoryName,
+ })
+ : await getJavaScriptTemplateForNewStoryFile({
+ basenameWithoutExtension,
+ componentExportName,
+ componentIsDefaultExport,
+ exportedStoryName,
+ });
+
+ const storyFilePath =
+ doesStoryFileExist(path.join(cwd, dirname), storyFileName) && componentExportCount > 1
+ ? path.join(cwd, dirname, alternativeStoryFileNameWithExtension)
+ : path.join(cwd, dirname, storyFileNameWithExtension);
+
+ return { storyFilePath, exportedStoryName, storyFileContent, dirname };
+}
+
+export const getStoryMetadata = (componentFilePath: string) => {
+ const isTypescript = /\.(ts|tsx|mts|cts)$/.test(componentFilePath);
+ const basename = path.basename(componentFilePath);
+ const extension = path.extname(componentFilePath);
+ const basenameWithoutExtension = basename.replace(extension, '');
+ const storyFileExtension = isTypescript ? 'tsx' : 'jsx';
+ return {
+ storyFileName: `${basenameWithoutExtension}.stories`,
+ storyFileExtension,
+ isTypescript,
+ };
+};
+
+export const doesStoryFileExist = (parentFolder: string, storyFileName: string) => {
+ return (
+ fs.existsSync(path.join(parentFolder, `${storyFileName}.ts`)) ||
+ fs.existsSync(path.join(parentFolder, `${storyFileName}.tsx`)) ||
+ fs.existsSync(path.join(parentFolder, `${storyFileName}.js`)) ||
+ fs.existsSync(path.join(parentFolder, `${storyFileName}.jsx`))
+ );
+};
diff --git a/code/lib/core-server/src/utils/get-story-id.test.ts b/code/lib/core-server/src/utils/get-story-id.test.ts
new file mode 100644
index 000000000000..243fbed6160f
--- /dev/null
+++ b/code/lib/core-server/src/utils/get-story-id.test.ts
@@ -0,0 +1,46 @@
+import path from 'path';
+import { describe, expect, it } from 'vitest';
+import { getStoryId } from './get-story-id';
+
+describe('getStoryId', () => {
+ it('should return the storyId', async () => {
+ const cwd = process.cwd();
+ const options = {
+ configDir: path.join(cwd, '.storybook'),
+ presets: {
+ apply: (val: string) => {
+ if (val === 'stories') {
+ return Promise.resolve(['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)']);
+ }
+ },
+ },
+ } as any;
+ const storyFilePath = path.join(cwd, 'src', 'components', 'stories', 'Page1.stories.ts');
+ const exportedStoryName = 'Default';
+
+ const { storyId, kind } = await getStoryId({ storyFilePath, exportedStoryName }, options);
+
+ expect(storyId).toBe('components-stories-page1--default');
+ expect(kind).toBe('components-stories-page1');
+ });
+
+ it('should throw an error if the storyId cannot be calculated', async () => {
+ const cwd = process.cwd();
+ const options = {
+ configDir: path.join(cwd, '.storybook'),
+ presets: {
+ apply: (val: string) => {
+ if (val === 'stories') {
+ return Promise.resolve(['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)']);
+ }
+ },
+ },
+ } as any;
+ const storyFilePath = path.join(cwd, 'not-covered-path', 'stories', 'Page1.stories.ts');
+ const exportedStoryName = 'Default';
+
+ await expect(() =>
+ getStoryId({ storyFilePath, exportedStoryName }, options)
+ ).rejects.toThrowError();
+ });
+});
diff --git a/code/lib/core-server/src/utils/get-story-id.ts b/code/lib/core-server/src/utils/get-story-id.ts
new file mode 100644
index 000000000000..eb73a52ae58a
--- /dev/null
+++ b/code/lib/core-server/src/utils/get-story-id.ts
@@ -0,0 +1,44 @@
+import type { Options } from '@storybook/types';
+import dedent from 'ts-dedent';
+import { normalizeStories, normalizeStoryPath } from '@storybook/core-common';
+import path from 'path';
+import { sanitize, storyNameFromExport, toId } from '@storybook/csf';
+import { userOrAutoTitleFromSpecifier } from '@storybook/preview-api';
+import { posix } from './posix';
+
+interface StoryIdData {
+ storyFilePath: string;
+ exportedStoryName: string;
+}
+
+export async function getStoryId(data: StoryIdData, options: Options) {
+ const stories = await options.presets.apply('stories', [], options);
+
+ const workingDir = process.cwd();
+
+ const normalizedStories = normalizeStories(stories, {
+ configDir: options.configDir,
+ workingDir,
+ });
+
+ const relativePath = path.relative(workingDir, data.storyFilePath);
+ const importPath = posix(normalizeStoryPath(relativePath));
+
+ const autoTitle = normalizedStories
+ .map((normalizeStory) => userOrAutoTitleFromSpecifier(importPath, normalizeStory))
+ .filter(Boolean)[0];
+
+ if (autoTitle === undefined) {
+ // eslint-disable-next-line local-rules/no-uncategorized-errors
+ throw new Error(dedent`
+ The new story file was successfully generated, but we are unable to index it. Please ensure that the new Story file is matched by the 'stories' glob pattern in your Storybook configuration.
+ `);
+ }
+
+ const storyName = storyNameFromExport(data.exportedStoryName);
+
+ const storyId = toId(autoTitle as string, storyName);
+ const kind = sanitize(autoTitle);
+
+ return { storyId, kind };
+}
diff --git a/code/lib/core-server/src/utils/new-story-templates/javascript.test.ts b/code/lib/core-server/src/utils/new-story-templates/javascript.test.ts
new file mode 100644
index 000000000000..525c0fe25f4c
--- /dev/null
+++ b/code/lib/core-server/src/utils/new-story-templates/javascript.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it } from 'vitest';
+import { getJavaScriptTemplateForNewStoryFile } from './javascript';
+
+describe('javascript', () => {
+ it('should return a TypeScript template with a default import', async () => {
+ const result = await getJavaScriptTemplateForNewStoryFile({
+ basenameWithoutExtension: 'foo',
+ componentExportName: 'default',
+ componentIsDefaultExport: true,
+ exportedStoryName: 'Default',
+ });
+
+ expect(result).toMatchInlineSnapshot(`
+ "import Foo from './foo';
+
+ const meta = {
+ component: Foo,
+ };
+
+ export default meta;
+
+ export const Default = {};"
+ `);
+ });
+
+ it('should return a TypeScript template with a named import', async () => {
+ const result = await getJavaScriptTemplateForNewStoryFile({
+ basenameWithoutExtension: 'foo',
+ componentExportName: 'Example',
+ componentIsDefaultExport: false,
+ exportedStoryName: 'Default',
+ });
+
+ expect(result).toMatchInlineSnapshot(`
+ "import { Example } from './foo';
+
+ const meta = {
+ component: Example,
+ };
+
+ export default meta;
+
+ export const Default = {};"
+ `);
+ });
+});
diff --git a/code/lib/core-server/src/utils/new-story-templates/javascript.ts b/code/lib/core-server/src/utils/new-story-templates/javascript.ts
new file mode 100644
index 000000000000..e80e4d4b5a57
--- /dev/null
+++ b/code/lib/core-server/src/utils/new-story-templates/javascript.ts
@@ -0,0 +1,32 @@
+import dedent from 'ts-dedent';
+import { getComponentVariableName } from '../get-component-variable-name';
+
+interface JavaScriptTemplateData {
+ /** The components file name without the extension */
+ basenameWithoutExtension: string;
+ componentExportName: string;
+ componentIsDefaultExport: boolean;
+ /** The exported name of the default story */
+ exportedStoryName: string;
+}
+
+export async function getJavaScriptTemplateForNewStoryFile(data: JavaScriptTemplateData) {
+ const importName = data.componentIsDefaultExport
+ ? await getComponentVariableName(data.basenameWithoutExtension)
+ : data.componentExportName;
+ const importStatement = data.componentIsDefaultExport
+ ? `import ${importName} from './${data.basenameWithoutExtension}';`
+ : `import { ${importName} } from './${data.basenameWithoutExtension}';`;
+
+ return dedent`
+ ${importStatement}
+
+ const meta = {
+ component: ${importName},
+ };
+
+ export default meta;
+
+ export const ${data.exportedStoryName} = {};
+ `;
+}
diff --git a/code/lib/core-server/src/utils/new-story-templates/typescript.test.ts b/code/lib/core-server/src/utils/new-story-templates/typescript.test.ts
new file mode 100644
index 000000000000..338b3209ce95
--- /dev/null
+++ b/code/lib/core-server/src/utils/new-story-templates/typescript.test.ts
@@ -0,0 +1,56 @@
+import { describe, expect, it } from 'vitest';
+import { getTypeScriptTemplateForNewStoryFile } from './typescript';
+
+describe('typescript', () => {
+ it('should return a TypeScript template with a default import', async () => {
+ const result = await getTypeScriptTemplateForNewStoryFile({
+ basenameWithoutExtension: 'foo',
+ componentExportName: 'default',
+ componentIsDefaultExport: true,
+ rendererPackage: '@storybook/react',
+ exportedStoryName: 'Default',
+ });
+
+ expect(result).toMatchInlineSnapshot(`
+ "import type { Meta, StoryObj } from '@storybook/react';
+
+ import Foo from './foo';
+
+ const meta = {
+ component: Foo,
+ } satisfies Meta;
+
+ export default meta;
+
+ type Story = StoryObj;
+
+ export const Default: Story = {};"
+ `);
+ });
+
+ it('should return a TypeScript template with a named import', async () => {
+ const result = await getTypeScriptTemplateForNewStoryFile({
+ basenameWithoutExtension: 'foo',
+ componentExportName: 'Example',
+ componentIsDefaultExport: false,
+ rendererPackage: '@storybook/react',
+ exportedStoryName: 'Default',
+ });
+
+ expect(result).toMatchInlineSnapshot(`
+ "import type { Meta, StoryObj } from '@storybook/react';
+
+ import { Example } from './foo';
+
+ const meta = {
+ component: Example,
+ } satisfies Meta;
+
+ export default meta;
+
+ type Story = StoryObj;
+
+ export const Default: Story = {};"
+ `);
+ });
+});
diff --git a/code/lib/core-server/src/utils/new-story-templates/typescript.ts b/code/lib/core-server/src/utils/new-story-templates/typescript.ts
new file mode 100644
index 000000000000..d2513673ebb5
--- /dev/null
+++ b/code/lib/core-server/src/utils/new-story-templates/typescript.ts
@@ -0,0 +1,38 @@
+import dedent from 'ts-dedent';
+import { getComponentVariableName } from '../get-component-variable-name';
+
+interface TypeScriptTemplateData {
+ /** The components file name without the extension */
+ basenameWithoutExtension: string;
+ componentExportName: string;
+ componentIsDefaultExport: boolean;
+ /** The renderer package name, e.g. @storybook/nextjs */
+ rendererPackage: string;
+ /** The exported name of the default story */
+ exportedStoryName: string;
+}
+
+export async function getTypeScriptTemplateForNewStoryFile(data: TypeScriptTemplateData) {
+ const importName = data.componentIsDefaultExport
+ ? await getComponentVariableName(data.basenameWithoutExtension)
+ : data.componentExportName;
+ const importStatement = data.componentIsDefaultExport
+ ? `import ${importName} from './${data.basenameWithoutExtension}'`
+ : `import { ${importName} } from './${data.basenameWithoutExtension}'`;
+
+ return dedent`
+ import type { Meta, StoryObj } from '${data.rendererPackage}';
+
+ ${importStatement};
+
+ const meta = {
+ component: ${importName},
+ } satisfies Meta;
+
+ export default meta;
+
+ type Story = StoryObj;
+
+ export const ${data.exportedStoryName}: Story = {};
+ `;
+}
diff --git a/code/lib/core-server/src/utils/parser/generic-parser.test.ts b/code/lib/core-server/src/utils/parser/generic-parser.test.ts
index 6d3ff96e15b0..61bba2739f72 100644
--- a/code/lib/core-server/src/utils/parser/generic-parser.test.ts
+++ b/code/lib/core-server/src/utils/parser/generic-parser.test.ts
@@ -8,34 +8,6 @@ const genericParser = new GenericParser();
const TEST_DIR = path.join(__dirname, '..', '__search-files-tests__');
describe('generic-parser', () => {
- it('should correctly return exports from CommonJS files', async () => {
- const content = fs.readFileSync(path.join(TEST_DIR, 'src', 'commonjs-module.js'), 'utf-8');
- const { exports } = await genericParser.parse(content);
-
- expect(exports).toEqual([
- {
- default: false,
- name: 'a',
- },
- {
- default: false,
- name: 'b',
- },
- {
- default: false,
- name: 'c',
- },
- {
- default: false,
- name: 'd',
- },
- {
- default: false,
- name: 'e',
- },
- ]);
- });
-
it('should correctly return exports from ES modules', async () => {
const content = fs.readFileSync(path.join(TEST_DIR, 'src', 'es-module.js'), 'utf-8');
const { exports } = await genericParser.parse(content);
diff --git a/code/lib/core-server/src/utils/parser/generic-parser.ts b/code/lib/core-server/src/utils/parser/generic-parser.ts
index e297c1e92eed..e3c4754fab44 100644
--- a/code/lib/core-server/src/utils/parser/generic-parser.ts
+++ b/code/lib/core-server/src/utils/parser/generic-parser.ts
@@ -1,8 +1,7 @@
-import { parse as parseCjs, init as initCjsParser } from 'cjs-module-lexer';
-import { parse as parseEs } from 'es-module-lexer';
-import assert from 'node:assert';
+import * as babelParser from '@babel/parser';
+import { types } from '@babel/core';
-import type { Parser } from './types';
+import type { Parser, ParserResult } from './types';
/**
* A generic parser that can parse both ES and CJS modules.
@@ -13,41 +12,109 @@ export class GenericParser implements Parser {
* @param content The content of the file
* @returns The exports of the file
*/
- async parse(content: string) {
- try {
- // Do NOT remove await here. The types are wrong! It has to be awaited,
- // otherwise it will return a Promise> when wasm isn't loaded.
- const [, exports] = await parseEs(content);
+ async parse(content: string): Promise {
+ const ast = babelParser.parse(content, {
+ allowImportExportEverywhere: true,
+ allowAwaitOutsideFunction: true,
+ allowNewTargetOutsideFunction: true,
+ allowReturnOutsideFunction: true,
+ allowUndeclaredExports: true,
+ plugins: [
+ // Language features
+ 'typescript',
+ 'jsx',
+ // Latest ECMAScript features
+ 'asyncGenerators',
+ 'bigInt',
+ 'classProperties',
+ 'classPrivateProperties',
+ 'classPrivateMethods',
+ 'classStaticBlock',
+ 'dynamicImport',
+ 'exportNamespaceFrom',
+ 'logicalAssignment',
+ 'moduleStringNames',
+ 'nullishCoalescingOperator',
+ 'numericSeparator',
+ 'objectRestSpread',
+ 'optionalCatchBinding',
+ 'optionalChaining',
+ 'privateIn',
+ 'regexpUnicodeSets',
+ 'topLevelAwait',
+ // ECMAScript proposals
+ 'asyncDoExpressions',
+ 'decimal',
+ 'decorators',
+ 'decoratorAutoAccessors',
+ 'deferredImportEvaluation',
+ 'destructuringPrivate',
+ 'doExpressions',
+ 'explicitResourceManagement',
+ 'exportDefaultFrom',
+ 'functionBind',
+ 'functionSent',
+ 'importAttributes',
+ 'importReflection',
+ 'moduleBlocks',
+ 'partialApplication',
+ 'recordAndTuple',
+ 'sourcePhaseImports',
+ 'throwExpressions',
+ ],
+ });
- assert(
- exports.length > 0,
- 'No named exports found. Very likely that this is not a ES module.'
- );
+ const exports: ParserResult['exports'] = [];
- return {
- exports: (exports ?? []).map((e) => {
- const name = content.substring(e.s, e.e);
- return {
- name,
- default: name === 'default',
- };
- }),
- };
- // Try to parse as CJS module
- } catch {
- await initCjsParser();
+ ast.program.body.forEach(function traverse(node) {
+ if (types.isExportNamedDeclaration(node)) {
+ // Handles function declarations: `export function a() {}`
+ if (
+ types.isFunctionDeclaration(node.declaration) &&
+ types.isIdentifier(node.declaration.id)
+ ) {
+ exports.push({
+ name: node.declaration.id.name,
+ default: false,
+ });
+ }
+ // Handles class declarations: `export class A {}`
+ if (types.isClassDeclaration(node.declaration) && types.isIdentifier(node.declaration.id)) {
+ exports.push({
+ name: node.declaration.id.name,
+ default: false,
+ });
+ }
+ // Handles export specifiers: `export { a }`
+ if (node.declaration === null && node.specifiers.length > 0) {
+ node.specifiers.forEach((specifier) => {
+ if (types.isExportSpecifier(specifier) && types.isIdentifier(specifier.exported)) {
+ exports.push({
+ name: specifier.exported.name,
+ default: false,
+ });
+ }
+ });
+ }
+ if (types.isVariableDeclaration(node.declaration)) {
+ node.declaration.declarations.forEach((declaration) => {
+ // Handle variable declarators: `export const a = 1;`
+ if (types.isVariableDeclarator(declaration) && types.isIdentifier(declaration.id)) {
+ exports.push({
+ name: declaration.id.name,
+ default: false,
+ });
+ }
+ });
+ }
+ } else if (types.isExportDefaultDeclaration(node)) {
+ exports.push({
+ name: 'default',
+ default: true,
+ });
+ }
+ });
- const { exports, reexports } = parseCjs(content);
- const filteredExports = [...exports, ...reexports].filter((e: string) => e !== '__esModule');
-
- assert(filteredExports.length > 0, 'No named exports found');
-
- return {
- exports: (filteredExports ?? []).map((name) => ({
- name,
- default: name === 'default',
- })),
- };
- }
+ return { exports };
}
}
diff --git a/code/lib/core-server/src/utils/posix.test.ts b/code/lib/core-server/src/utils/posix.test.ts
new file mode 100644
index 000000000000..23c8d2ca3bec
--- /dev/null
+++ b/code/lib/core-server/src/utils/posix.test.ts
@@ -0,0 +1,9 @@
+import { describe, expect, it } from 'vitest';
+import { posix } from './posix';
+
+describe('posix', () => {
+ it('should replace backslashes with forward slashes', () => {
+ expect(posix('src\\components\\Page.tsx', '\\')).toBe('src/components/Page.tsx');
+ expect(posix('src\\\\components\\\\Page.tsx', '\\\\')).toBe('src/components/Page.tsx');
+ });
+});
diff --git a/code/lib/core-server/src/utils/posix.ts b/code/lib/core-server/src/utils/posix.ts
new file mode 100644
index 000000000000..d9b8224cdde5
--- /dev/null
+++ b/code/lib/core-server/src/utils/posix.ts
@@ -0,0 +1,7 @@
+import path from 'node:path';
+
+/**
+ * Replaces the path separator with forward slashes
+ */
+export const posix = (localPath: string, sep: string = path.sep) =>
+ localPath.split(sep).filter(Boolean).join(path.posix.sep);
diff --git a/code/lib/core-server/src/utils/save-story/duplicate-story-with-new-name.test.ts b/code/lib/core-server/src/utils/save-story/duplicate-story-with-new-name.test.ts
new file mode 100644
index 000000000000..59250060f5d2
--- /dev/null
+++ b/code/lib/core-server/src/utils/save-story/duplicate-story-with-new-name.test.ts
@@ -0,0 +1,121 @@
+/* eslint-disable no-underscore-dangle */
+import { describe, test, expect } from 'vitest';
+import { readCsf, printCsf } from '@storybook/csf-tools';
+
+import { duplicateStoryWithNewName } from './duplicate-story-with-new-name';
+import { readFile } from 'fs/promises';
+import { join } from 'path';
+import { format } from 'prettier';
+import { getDiff } from './getDiff';
+
+const makeTitle = (userTitle: string) => userTitle;
+
+const FILES = {
+ csfVariances: join(__dirname, 'mocks/csf-variances.stories.tsx'),
+ unsupportedCsfVariances: join(__dirname, 'mocks/unsupported-csf-variances.stories.tsx'),
+ typescriptConstructs: join(__dirname, 'mocks/typescript-constructs.stories.tsx'),
+};
+
+describe('success', () => {
+ test('CSF Variances', async () => {
+ const before = await format(await readFile(FILES.csfVariances, 'utf-8'), {
+ parser: 'typescript',
+ });
+ const CSF = await readCsf(FILES.csfVariances, { makeTitle });
+
+ const parsed = CSF.parse();
+ const names = Object.keys(parsed._stories);
+
+ names.forEach((name) => {
+ duplicateStoryWithNewName(parsed, name, name + 'Duplicated');
+ });
+
+ const after = await format(printCsf(parsed).code, {
+ parser: 'typescript',
+ });
+
+ // check if the code was updated at all
+ expect(after).not.toBe(before);
+
+ // check if the code was updated correctly
+ expect(getDiff(before, after)).toMatchInlineSnapshot(`
+ " ...
+ canvasElement.style.backgroundColor = "red";
+ },
+ } satisfies Story;
+
+ + export const EmptyDuplicated = {} satisfies Story;
+ + export const EmptyWithCommentDuplicated = {} satisfies Story;
+ + export const OnlyArgsDuplicated = {} satisfies Story;
+ +
+ + export const RenderNoArgsDuplicated = {
+ + render: (args) => ,
+ + } satisfies Story;
+ +
+ + export const RenderArgsDuplicated = {
+ + render: (args) => ,
+ + } satisfies Story;
+ +
+ + export const RenderExistingArgsDuplicated = {
+ + render: (args) => ,
+ + } satisfies Story;
+ +
+ + export const OrderedArgsDuplicated = {
+ + render: (args) => ,
+ + } satisfies Story;
+ +
+ + export const HasPlayFunctionDuplicated = {
+ + play: async ({ canvasElement }) => {
+ + console.log("play");
+ +
+ + canvasElement.style.backgroundColor = "red";
+ + },
+ + } satisfies Story;
+ + "
+ `);
+ });
+ test('Unsupported CSF Variances', async () => {
+ const CSF = await readCsf(FILES.unsupportedCsfVariances, { makeTitle });
+
+ const parsed = CSF.parse();
+ const names = Object.keys(parsed._stories);
+
+ names.forEach((name) => {
+ expect(() => duplicateStoryWithNewName(parsed, name, name + 'Duplicated')).toThrow();
+ });
+ });
+ test('Typescript Constructs', async () => {
+ const before = await format(await readFile(FILES.typescriptConstructs, 'utf-8'), {
+ parser: 'typescript',
+ });
+ const CSF = await readCsf(FILES.typescriptConstructs, { makeTitle });
+
+ const parsed = CSF.parse();
+ const names = Object.keys(parsed._stories);
+
+ names.forEach((name) => {
+ duplicateStoryWithNewName(parsed, name, name + 'Duplicated');
+ });
+
+ const after = await format(printCsf(parsed).code, {
+ parser: 'typescript',
+ });
+
+ // check if the code was updated at all
+ expect(after).not.toBe(before);
+
+ // check if the code was updated correctly
+ expect(getDiff(before, after)).toMatchInlineSnapshot(`
+ " ...
+ initial: "bar",
+ },
+ };
+
+ + export const CastDuplicated: Story = {};
+ + export const AsDuplicated = {} as Story;
+ + export const SatisfiesDuplicated = {} satisfies Story;
+ + export const NoneDuplicated = {};
+ + "
+ `);
+ });
+});
diff --git a/code/lib/core-server/src/utils/save-story/duplicate-story-with-new-name.ts b/code/lib/core-server/src/utils/save-story/duplicate-story-with-new-name.ts
new file mode 100644
index 000000000000..3ab01043752d
--- /dev/null
+++ b/code/lib/core-server/src/utils/save-story/duplicate-story-with-new-name.ts
@@ -0,0 +1,54 @@
+/* eslint-disable no-underscore-dangle */
+import type { CsfFile } from '@storybook/csf-tools';
+import * as traverse from '@babel/traverse';
+import * as t from '@babel/types';
+import { SaveStoryError } from './utils';
+
+type In = ReturnType;
+
+export const duplicateStoryWithNewName = (csfFile: In, storyName: string, newStoryName: string) => {
+ const node = csfFile._storyExports[storyName];
+ const cloned = t.cloneNode(node) as t.VariableDeclarator;
+
+ if (!cloned) {
+ throw new SaveStoryError(`cannot clone Node`);
+ }
+
+ let found = false;
+ traverse.default(cloned, {
+ Identifier(path) {
+ if (found) {
+ return;
+ }
+
+ if (path.node.name === storyName) {
+ found = true;
+ path.node.name = newStoryName;
+ }
+ },
+ ObjectProperty(path) {
+ const key = path.get('key');
+ if (key.isIdentifier() && key.node.name === 'args') {
+ path.remove();
+ }
+ },
+
+ noScope: true,
+ });
+
+ // detect CSF2 and throw
+ if (t.isArrowFunctionExpression(cloned.init) || t.isCallExpression(cloned.init)) {
+ throw new SaveStoryError(`Creating a new story based on a CSF2 story is not supported`);
+ }
+
+ traverse.default(csfFile._ast, {
+ Program(path) {
+ path.pushContainer(
+ 'body',
+ t.exportNamedDeclaration(t.variableDeclaration('const', [cloned]))
+ );
+ },
+ });
+
+ return cloned;
+};
diff --git a/code/lib/core-server/src/utils/save-story/getDiff.ts b/code/lib/core-server/src/utils/save-story/getDiff.ts
new file mode 100644
index 000000000000..df3462c9ea08
--- /dev/null
+++ b/code/lib/core-server/src/utils/save-story/getDiff.ts
@@ -0,0 +1,67 @@
+import { diffLines } from 'diff';
+
+/**
+ * Get a diff between two strings
+ * @param before The original string
+ * @param after The new string
+ * @returns The diff as a string
+ * @example
+ * ```ts
+ * const before = 'foo\nbar\nbaz';
+ * const after = 'foo\nbaz';
+ * const diff = getDiff(before, after);
+ * console.log(diff);
+ * // foo
+ * // - bar
+ * // baz
+ * ```
+ */
+export function getDiff(before: string, after: string): string {
+ const context = 4;
+ return diffLines(before, after, {})
+ .map((r, index, l) => {
+ const lines = r.value.split('\n');
+
+ if (r.removed) {
+ return r.value
+ .split('\n')
+ .map((v) => `- ${v}`)
+ .join('\n');
+ }
+ if (r.added) {
+ return r.value
+ .split('\n')
+ .map((v) => `+ ${v}`)
+ .join('\n');
+ }
+
+ if (index === 0) {
+ const sliced = lines.slice(0 - context);
+
+ if (sliced.length !== lines.length) {
+ sliced.unshift('...');
+ }
+ return sliced.map((v) => ` ${v}`).join('\n');
+ }
+
+ if (index === l.length - 1) {
+ const sliced = lines.slice(0, context);
+
+ if (sliced.length !== lines.length) {
+ sliced.push('...');
+ }
+ return sliced.map((v) => ` ${v}`).join('\n');
+ }
+
+ if (lines.length <= context * 2 + 1) {
+ return lines.map((v) => ` ${v}`).join('\n');
+ }
+ return [
+ //
+ ...lines.slice(0, context).map((v) => ` ${v}`),
+ '...',
+ ...lines.slice(0 - context).map((v) => ` ${v}`),
+ ].join('\n');
+ })
+ .join('\n');
+}
diff --git a/code/lib/core-server/src/utils/save-story/mocks/csf-variances.stories.tsx b/code/lib/core-server/src/utils/save-story/mocks/csf-variances.stories.tsx
new file mode 100644
index 000000000000..9fcbc4bd4df3
--- /dev/null
+++ b/code/lib/core-server/src/utils/save-story/mocks/csf-variances.stories.tsx
@@ -0,0 +1,73 @@
+import React from 'react';
+import type { FC } from 'react';
+import type { Meta, StoryObj } from '@storybook/react';
+
+export default {
+ title: 'MyComponent',
+ args: {
+ initial: 'foo',
+ },
+} satisfies Meta;
+
+type Story = StoryObj;
+
+// dummy component
+const MyComponent: FC<{ absolute: boolean; bordered: boolean; initial: string }> = (props) => (
+ {JSON.stringify(props)}
+);
+
+export const Empty = {} satisfies Story;
+
+export const EmptyWithComment = {
+ // this is a useless comment, to test that it is preserved
+} satisfies Story;
+
+export const OnlyArgs = {
+ args: {
+ absolute: true,
+ },
+} satisfies Story;
+
+export const RenderNoArgs = {
+ render: (args) => ,
+} satisfies Story;
+
+export const RenderArgs = {
+ args: {
+ absolute: true,
+ },
+ render: (args) => ,
+} satisfies Story;
+
+export const RenderExistingArgs = {
+ args: {
+ absolute: true,
+ bordered: true,
+ initial: 'test2',
+ },
+ render: (args) => ,
+} satisfies Story;
+
+// The order of both the properties of the story and the order or args should be preserved
+export const OrderedArgs = {
+ args: {
+ bordered: true,
+ initial: 'test2',
+ absolute: true,
+ },
+ render: (args) => ,
+} satisfies Story;
+
+// The order of both the properties of the story and the order or args should be preserved
+export const HasPlayFunction = {
+ args: {
+ bordered: true,
+ initial: 'test2',
+ absolute: true,
+ },
+ play: async ({ canvasElement }) => {
+ console.log('play');
+
+ canvasElement.style.backgroundColor = 'red';
+ },
+} satisfies Story;
diff --git a/code/lib/core-server/src/utils/save-story/mocks/data-variances.stories.tsx b/code/lib/core-server/src/utils/save-story/mocks/data-variances.stories.tsx
new file mode 100644
index 000000000000..ea2f79bdcc3b
--- /dev/null
+++ b/code/lib/core-server/src/utils/save-story/mocks/data-variances.stories.tsx
@@ -0,0 +1,45 @@
+import React from 'react';
+import type { FC } from 'react';
+import type { Meta, StoryObj } from '@storybook/react';
+
+export default {
+ title: 'MyComponent',
+ args: {
+ myString: 'foo',
+ },
+} satisfies Meta;
+
+type Story = StoryObj;
+
+// dummy component
+const MyComponent: FC<{
+ myUndefined: undefined;
+ myNull: null;
+ myBoolean: boolean;
+ myString: string;
+ myNumber: number;
+ myArray: string[];
+ myArrayDeep: string[][];
+ myObject: object;
+ myFunction: () => void;
+}> = (props) => {JSON.stringify(props)} ;
+
+export const All = {
+ args: {
+ myArray: ['foo', 'bar'],
+ myArrayDeep: [['foo'], ['bar']],
+ myBoolean: true,
+ myFunction: () => {},
+ myNull: null,
+ myNumber: 42,
+ myObject: {
+ foo: 'bar',
+ },
+ myString: 'foo',
+ myUndefined: undefined,
+ },
+} satisfies Story;
+
+export const None = {
+ args: {},
+} satisfies Story;
diff --git a/code/lib/core-server/src/utils/save-story/mocks/export-variances.stories.tsx b/code/lib/core-server/src/utils/save-story/mocks/export-variances.stories.tsx
new file mode 100644
index 000000000000..7c853f484468
--- /dev/null
+++ b/code/lib/core-server/src/utils/save-story/mocks/export-variances.stories.tsx
@@ -0,0 +1,37 @@
+import React from 'react';
+import type { FC } from 'react';
+import type { Meta, StoryObj } from '@storybook/react';
+
+export default {
+ title: 'MyComponent',
+ args: {
+ initial: 'foo',
+ },
+} satisfies Meta;
+
+type Story = StoryObj;
+
+// dummy component
+const MyComponent: FC<{ absolute: boolean; bordered: boolean; initial: string }> = (props) => (
+ {JSON.stringify(props)}
+);
+
+export const DirectExport: Story = {
+ args: {
+ initial: 'bar',
+ },
+};
+
+const BlockExport: Story = {
+ args: {
+ initial: 'bar',
+ },
+};
+
+const NotYetRenamedExport: Story = {
+ args: {
+ initial: 'bar',
+ },
+};
+
+export { BlockExport, NotYetRenamedExport as RenamedExport };
diff --git a/code/lib/core-server/src/utils/save-story/mocks/typescript-constructs.stories.tsx b/code/lib/core-server/src/utils/save-story/mocks/typescript-constructs.stories.tsx
new file mode 100644
index 000000000000..d44c95c446be
--- /dev/null
+++ b/code/lib/core-server/src/utils/save-story/mocks/typescript-constructs.stories.tsx
@@ -0,0 +1,41 @@
+import React from 'react';
+import type { FC } from 'react';
+import type { Meta, StoryObj } from '@storybook/react';
+
+export default {
+ title: 'MyComponent',
+ args: {
+ initial: 'foo',
+ },
+} satisfies Meta;
+
+type Story = StoryObj;
+
+// dummy component
+const MyComponent: FC<{ absolute: boolean; bordered: boolean; initial: string }> = (props) => (
+ {JSON.stringify(props)}
+);
+
+export const Cast: Story = {
+ args: {
+ initial: 'bar',
+ },
+};
+
+export const As = {
+ args: {
+ initial: 'bar',
+ },
+} as Story;
+
+export const Satisfies = {
+ args: {
+ initial: 'bar',
+ },
+} satisfies Story;
+
+export const None = {
+ args: {
+ initial: 'bar',
+ },
+};
diff --git a/code/lib/core-server/src/utils/save-story/mocks/unsupported-csf-variances.stories.tsx b/code/lib/core-server/src/utils/save-story/mocks/unsupported-csf-variances.stories.tsx
new file mode 100644
index 000000000000..b86095381faa
--- /dev/null
+++ b/code/lib/core-server/src/utils/save-story/mocks/unsupported-csf-variances.stories.tsx
@@ -0,0 +1,19 @@
+import React from 'react';
+import type { FC } from 'react';
+import type { Meta } from '@storybook/react';
+
+export default {
+ title: 'MyComponent',
+ args: {
+ initial: 'foo',
+ },
+} satisfies Meta;
+
+// dummy component
+const MyComponent: FC<{ absolute: boolean; bordered: boolean; initial: string }> = (props) => (
+ {JSON.stringify(props)}
+);
+
+export const CSF2 = () => ;
+
+export const CSF2b = CSF2.bind({});
diff --git a/code/lib/core-server/src/utils/save-story/save-story.ts b/code/lib/core-server/src/utils/save-story/save-story.ts
new file mode 100644
index 000000000000..03e359e85c03
--- /dev/null
+++ b/code/lib/core-server/src/utils/save-story/save-story.ts
@@ -0,0 +1,142 @@
+/* eslint-disable no-underscore-dangle */
+import fs from 'node:fs/promises';
+import type { Channel } from '@storybook/channels';
+import type {
+ RequestData,
+ ResponseData,
+ SaveStoryRequestPayload,
+ SaveStoryResponsePayload,
+} from '@storybook/core-events';
+import { SAVE_STORY_REQUEST, SAVE_STORY_RESPONSE, STORY_RENDERED } from '@storybook/core-events';
+import { storyNameFromExport, toId } from '@storybook/csf';
+import { printCsf, readCsf } from '@storybook/csf-tools';
+import { logger } from '@storybook/node-logger';
+import type { CoreConfig, Options } from '@storybook/types';
+import { telemetry } from '@storybook/telemetry';
+
+import { basename, join } from 'path';
+import { updateArgsInCsfFile } from './update-args-in-csf-file';
+import { duplicateStoryWithNewName } from './duplicate-story-with-new-name';
+import { formatFileContent } from '@storybook/core-common';
+import { SaveStoryError } from './utils';
+
+const parseArgs = (args: string): Record =>
+ JSON.parse(args, (_, value) => {
+ if (value === '__sb_empty_function_arg__') {
+ return () => {};
+ }
+ return value;
+ });
+
+// Removes extra newlines between story properties. See https://github.com/benjamn/recast/issues/242
+// Only updates the part of the code for the story with the given name.
+const removeExtraNewlines = (code: string, name: string) => {
+ const anything = '(.|\r\n|\r|\n)'; // Multiline match for any character.
+ const newline = '(\r\n|\r|\n)'; // Either newlines or carriage returns may be used in the file.
+ const closing = newline + '};' + newline; // Marks the end of the story definition.
+ const regex = new RegExp(
+ // Looks for an export by the given name, considers the first closing brace on its own line
+ // to be the end of the story definition.
+ `^(?${anything}*)(?export const ${name} =${anything}+?${closing})(?${anything}*)$`
+ );
+ const { before, story, after } = code.match(regex)?.groups || {};
+ return story
+ ? before + story.replaceAll(/(\r\n|\r|\n)(\r\n|\r|\n)([ \t]*[a-z0-9_]+): /gi, '$2$3:') + after
+ : code;
+};
+
+export function initializeSaveStory(channel: Channel, options: Options, coreConfig: CoreConfig) {
+ channel.on(SAVE_STORY_REQUEST, async ({ id, payload }: RequestData) => {
+ const { csfId, importPath, args, name } = payload;
+
+ let newStoryId: string | undefined;
+ let newStoryName: string | undefined;
+ let sourceFileName: string | undefined;
+ let sourceFilePath: string | undefined;
+ let sourceStoryName: string | undefined;
+
+ try {
+ sourceFileName = basename(importPath);
+ sourceFilePath = join(process.cwd(), importPath);
+
+ const csf = await readCsf(sourceFilePath, {
+ makeTitle: (userTitle: string) => userTitle || 'myTitle',
+ });
+
+ const parsed = csf.parse();
+ const stories = Object.entries(parsed._stories);
+
+ const [componentId, storyId] = csfId.split('--');
+ newStoryName = name && storyNameFromExport(name);
+ newStoryId = newStoryName && toId(componentId, newStoryName);
+
+ const [storyName] = stories.find(([key, value]) => value.id.endsWith(`--${storyId}`)) || [];
+ if (!storyName) {
+ throw new SaveStoryError(`Source story not found.`);
+ }
+ if (name && csf.getStoryExport(name)) {
+ throw new SaveStoryError(`Story already exists.`);
+ }
+
+ sourceStoryName = storyNameFromExport(storyName);
+
+ await updateArgsInCsfFile(
+ name ? duplicateStoryWithNewName(parsed, storyName, name) : csf.getStoryExport(storyName),
+ args ? parseArgs(args) : {}
+ );
+
+ const code = await formatFileContent(
+ sourceFilePath,
+ removeExtraNewlines(printCsf(csf).code, name || storyName)
+ );
+
+ // Writing the CSF file should trigger HMR, which causes the story to rerender. Delay the
+ // response until that happens, but don't wait too long.
+ await Promise.all([
+ new Promise((resolve) => {
+ channel.on(STORY_RENDERED, resolve);
+ setTimeout(() => resolve(channel.off(STORY_RENDERED, resolve)), 3000);
+ }),
+ fs.writeFile(sourceFilePath, code),
+ ]);
+
+ channel.emit(SAVE_STORY_RESPONSE, {
+ id,
+ success: true,
+ payload: {
+ csfId,
+ newStoryId,
+ newStoryName,
+ sourceFileName,
+ sourceStoryName,
+ },
+ error: null,
+ } satisfies ResponseData);
+
+ if (!coreConfig.disableTelemetry) {
+ await telemetry('save-story', {
+ action: name ? 'createStory' : 'updateStory',
+ success: true,
+ });
+ }
+ } catch (error: any) {
+ channel.emit(SAVE_STORY_RESPONSE, {
+ id,
+ success: false,
+ error: error instanceof SaveStoryError ? error.message : 'Unknown error',
+ } satisfies ResponseData);
+
+ logger.error(
+ `Error writing to ${sourceFilePath}:\n${error.stack || error.message || error.toString()}`
+ );
+
+ if (!coreConfig.disableTelemetry && !(error instanceof SaveStoryError)) {
+ await telemetry('save-story', {
+ action: name ? 'createStory' : 'updateStory',
+ success: false,
+ error,
+ });
+ }
+ }
+ });
+}
diff --git a/code/lib/core-server/src/utils/save-story/update-args-in-csf-file.test.ts b/code/lib/core-server/src/utils/save-story/update-args-in-csf-file.test.ts
new file mode 100644
index 000000000000..8f0f2fb6b737
--- /dev/null
+++ b/code/lib/core-server/src/utils/save-story/update-args-in-csf-file.test.ts
@@ -0,0 +1,378 @@
+/* eslint-disable no-underscore-dangle */
+import { describe, test, expect } from 'vitest';
+import { readCsf, printCsf } from '@storybook/csf-tools';
+
+import { updateArgsInCsfFile } from './update-args-in-csf-file';
+import { readFile } from 'fs/promises';
+import { join } from 'path';
+import { format } from 'prettier';
+import { getDiff } from './getDiff';
+
+const makeTitle = (userTitle: string) => userTitle;
+
+const FILES = {
+ typescriptConstructs: join(__dirname, 'mocks/typescript-constructs.stories.tsx'),
+ csfVariances: join(__dirname, 'mocks/csf-variances.stories.tsx'),
+ unsupportedCsfVariances: join(__dirname, 'mocks/unsupported-csf-variances.stories.tsx'),
+ exportVariances: join(__dirname, 'mocks/export-variances.stories.tsx'),
+ dataVariances: join(__dirname, 'mocks/data-variances.stories.tsx'),
+};
+
+describe('success', () => {
+ test('Typescript Constructs', async () => {
+ const newArgs = { bordered: true, initial: 'test1' };
+
+ const before = await format(await readFile(FILES.typescriptConstructs, 'utf-8'), {
+ parser: 'typescript',
+ });
+ const CSF = await readCsf(FILES.typescriptConstructs, { makeTitle });
+
+ const parsed = CSF.parse();
+ const names = Object.keys(parsed._stories);
+ const nodes = names.map((name) => CSF.getStoryExport(name));
+
+ nodes.forEach((node) => {
+ updateArgsInCsfFile(node, newArgs);
+ });
+
+ const after = await format(printCsf(parsed).code, {
+ parser: 'typescript',
+ });
+
+ // check if the code was updated at all
+ expect(after).not.toBe(before);
+
+ // check if the code was updated correctly
+ expect(getDiff(before, after)).toMatchInlineSnapshot(`
+ " ...
+
+ export const Cast: Story = {
+ args: {
+
+ - initial: "bar",
+ -
+ + initial: "test1",
+ + bordered: true,
+ +
+ },
+ };
+
+ export const As = {
+ args: {
+
+ - initial: "bar",
+ -
+ + initial: "test1",
+ + bordered: true,
+ +
+ },
+ } as Story;
+
+ export const Satisfies = {
+ args: {
+
+ - initial: "bar",
+ -
+ + initial: "test1",
+ + bordered: true,
+ +
+ },
+ } satisfies Story;
+
+ export const None = {
+ args: {
+
+ - initial: "bar",
+ -
+ + initial: "test1",
+ + bordered: true,
+ +
+ },
+ };
+ "
+ `);
+ });
+ test('Unsupported CSF Variances', async () => {
+ const newArgs = { bordered: true, initial: 'test1' };
+
+ const CSF = await readCsf(FILES.unsupportedCsfVariances, { makeTitle });
+ const parsed = CSF.parse();
+ const names = Object.keys(parsed._stories);
+ const nodes = names.map((name) => CSF.getStoryExport(name));
+
+ nodes.forEach((node) => {
+ expect(() => updateArgsInCsfFile(node, newArgs)).rejects.toThrowError();
+ });
+ });
+ test('CSF Variances', async () => {
+ const newArgs = { bordered: true, initial: 'test1' };
+
+ const before = await format(await readFile(FILES.csfVariances, 'utf-8'), {
+ parser: 'typescript',
+ });
+ const CSF = await readCsf(FILES.csfVariances, { makeTitle });
+
+ const parsed = CSF.parse();
+ const names = Object.keys(parsed._stories);
+ const nodes = names.map((name) => CSF.getStoryExport(name));
+
+ nodes.forEach((node) => {
+ updateArgsInCsfFile(node, newArgs);
+ });
+
+ const after = await format(printCsf(parsed).code, {
+ parser: 'typescript',
+ });
+
+ // check if the code was updated at all
+ expect(after).not.toBe(before);
+
+ // check if the code was updated correctly
+ // TODO, the comment is not preserved!!!
+ expect(getDiff(before, after)).toMatchInlineSnapshot(`
+ " ...
+ initial: string;
+ }> = (props) => {JSON.stringify(props)} ;
+
+
+ - export const Empty = {} satisfies Story;
+ -
+ + export const Empty = {
+ + args: {
+ + bordered: true,
+ + initial: "test1",
+ + },
+ + } satisfies Story;
+ +
+
+ export const EmptyWithComment = {
+
+ - // this is a useless comment, to test that it is preserved
+ -
+ + args: {
+ + bordered: true,
+ + initial: "test1",
+ + },
+ +
+ } satisfies Story;
+
+ export const OnlyArgs = {
+ args: {
+ absolute: true,
+
+ + bordered: true,
+ + initial: "test1",
+ +
+ },
+ } satisfies Story;
+
+ export const RenderNoArgs = {
+
+ + args: {
+ + bordered: true,
+ + initial: "test1",
+ + },
+ +
+ +
+ render: (args) => ,
+ } satisfies Story;
+
+ export const RenderArgs = {
+ args: {
+ absolute: true,
+
+ + bordered: true,
+ + initial: "test1",
+ +
+ },
+ render: (args) => ,
+ } satisfies Story;
+
+ export const RenderExistingArgs = {
+ args: {
+ absolute: true,
+ bordered: true,
+
+ - initial: "test2",
+ -
+ + initial: "test1",
+ +
+ },
+ render: (args) => ,
+ } satisfies Story;
+
+ // The order of both the properties of the story and the order or args should be preserved
+ export const OrderedArgs = {
+ args: {
+ bordered: true,
+
+ - initial: "test2",
+ -
+ + initial: "test1",
+ +
+ absolute: true,
+ },
+ render: (args) => ,
+ } satisfies Story;
+ ...
+ export const HasPlayFunction = {
+ args: {
+ bordered: true,
+
+ - initial: "test2",
+ -
+ + initial: "test1",
+ +
+ absolute: true,
+ },
+ play: async ({ canvasElement }) => {
+ console.log("play");
+ ..."
+ `);
+ });
+ test('Export Variances', async () => {
+ const newArgs = { bordered: true, initial: 'test1' };
+
+ const before = await format(await readFile(FILES.exportVariances, 'utf-8'), {
+ parser: 'typescript',
+ });
+ const CSF = await readCsf(FILES.exportVariances, { makeTitle });
+
+ const parsed = CSF.parse();
+ const names = Object.keys(parsed._stories);
+ const nodes = names.map((name) => CSF.getStoryExport(name));
+
+ nodes.forEach((node) => {
+ if (node === undefined) {
+ return;
+ }
+ updateArgsInCsfFile(node, newArgs);
+ });
+
+ const after = await format(printCsf(parsed).code, {
+ parser: 'typescript',
+ });
+
+ // check if the code was updated at all
+ expect(after).not.toBe(before);
+
+ // check if the code was updated correctly
+ // TODO this is incomplete due to no support for export variances in csf-tools
+ expect(getDiff(before, after)).toMatchInlineSnapshot(`
+ " ...
+
+ export const DirectExport: Story = {
+ args: {
+
+ - initial: "bar",
+ -
+ + initial: "test1",
+ + bordered: true,
+ +
+ },
+ };
+
+ const BlockExport: Story = {
+ ..."
+ `);
+ });
+ test('Data Variances', async () => {
+ const newArgs = {
+ myArray: ['FOO', 'BAR'],
+ myArrayDeep: [['FOO'], ['BAR']],
+ myBoolean: true,
+ myFunction: () => {},
+ myNull: null,
+ myNumber: 41,
+ myObject: {
+ FOO: 'BAR',
+ },
+ myString: 'FOO',
+ myUndefined: undefined,
+ };
+
+ const before = await format(await readFile(FILES.dataVariances, 'utf-8'), {
+ parser: 'typescript',
+ });
+ const CSF = await readCsf(FILES.dataVariances, { makeTitle });
+
+ const parsed = CSF.parse();
+ const names = Object.keys(parsed._stories);
+ const nodes = names.map((name) => CSF.getStoryExport(name));
+
+ nodes.forEach((node) => {
+ if (node === undefined) {
+ return;
+ }
+ updateArgsInCsfFile(node, newArgs);
+ });
+
+ const after = await format(printCsf(parsed).code, {
+ parser: 'typescript',
+ });
+
+ // check if the code was updated at all
+ expect(after).not.toBe(before);
+
+ // check if the code was updated correctly
+ expect(getDiff(before, after)).toMatchInlineSnapshot(`
+ " ...
+
+ export const All = {
+ args: {
+
+ - myArray: ["foo", "bar"],
+ - myArrayDeep: [["foo"], ["bar"]],
+ -
+ + myArray: ["FOO", "BAR"],
+ + myArrayDeep: [["FOO"], ["BAR"]],
+ +
+ myBoolean: true,
+ myFunction: () => {},
+ myNull: null,
+
+ - myNumber: 42,
+ -
+ + myNumber: 41,
+ +
+ myObject: {
+
+ - foo: "bar",
+ -
+ + FOO: "BAR",
+ +
+ },
+
+ - myString: "foo",
+ -
+ + myString: "FOO",
+ +
+ myUndefined: undefined,
+ },
+ } satisfies Story;
+
+ export const None = {
+
+ - args: {},
+ -
+ + args: {
+ + myArray: ["FOO", "BAR"],
+ + myArrayDeep: [["FOO"], ["BAR"]],
+ + myBoolean: true,
+ + myFunction: () => {},
+ + myNull: null,
+ + myNumber: 41,
+ +
+ + myObject: {
+ + FOO: "BAR",
+ + },
+ +
+ + myString: "FOO",
+ + myUndefined: undefined,
+ + },
+ +
+ } satisfies Story;
+ "
+ `);
+ });
+});
diff --git a/code/lib/core-server/src/utils/save-story/update-args-in-csf-file.ts b/code/lib/core-server/src/utils/save-story/update-args-in-csf-file.ts
new file mode 100644
index 000000000000..f32db42f7978
--- /dev/null
+++ b/code/lib/core-server/src/utils/save-story/update-args-in-csf-file.ts
@@ -0,0 +1,119 @@
+import * as t from '@babel/types';
+import * as traverse from '@babel/traverse';
+import { valueToAST } from './valueToAST';
+import { SaveStoryError } from './utils';
+
+export const updateArgsInCsfFile = async (node: t.Node, input: Record) => {
+ let found = false;
+ const args = Object.fromEntries(
+ Object.entries(input).map(([k, v]) => {
+ return [k, valueToAST(v)];
+ })
+ );
+
+ // detect CSF2 and throw
+ if (t.isArrowFunctionExpression(node) || t.isCallExpression(node)) {
+ throw new SaveStoryError(`Updating a CSF2 story is not supported`);
+ }
+
+ if (t.isObjectExpression(node)) {
+ const properties = node.properties;
+ const argsProperty = properties.find((property) => {
+ if (t.isObjectProperty(property)) {
+ const key = property.key;
+ return t.isIdentifier(key) && key.name === 'args';
+ }
+ return false;
+ });
+
+ if (argsProperty) {
+ if (t.isObjectProperty(argsProperty)) {
+ const a = argsProperty.value;
+ if (t.isObjectExpression(a)) {
+ a.properties.forEach((p) => {
+ if (t.isObjectProperty(p)) {
+ const key = p.key;
+ if (t.isIdentifier(key) && key.name in args) {
+ p.value = args[key.name];
+ delete args[key.name];
+ }
+ }
+ });
+
+ const remainder = Object.entries(args);
+ if (Object.keys(args).length) {
+ remainder.forEach(([key, value]) => {
+ a.properties.push(t.objectProperty(t.identifier(key), value));
+ });
+ }
+ }
+ }
+ } else {
+ properties.unshift(
+ t.objectProperty(
+ t.identifier('args'),
+ t.objectExpression(
+ Object.entries(args).map(([key, value]) => t.objectProperty(t.identifier(key), value))
+ )
+ )
+ );
+ }
+ return;
+ }
+
+ traverse.default(node, {
+ ObjectExpression(path) {
+ if (found) {
+ return;
+ }
+
+ found = true;
+ const properties = path.get('properties');
+ const argsProperty = properties.find((property) => {
+ if (property.isObjectProperty()) {
+ const key = property.get('key');
+ return key.isIdentifier() && key.node.name === 'args';
+ }
+ return false;
+ });
+
+ if (argsProperty) {
+ if (argsProperty.isObjectProperty()) {
+ const a = argsProperty.get('value');
+ if (a.isObjectExpression()) {
+ a.traverse({
+ ObjectProperty(p) {
+ const key = p.get('key');
+ if (key.isIdentifier() && key.node.name in args) {
+ p.get('value').replaceWith(args[key.node.name]);
+ delete args[key.node.name];
+ }
+ },
+ // @ts-expect-error noScope works but is not typed properly
+ noScope: true,
+ });
+
+ const remainder = Object.entries(args);
+ if (Object.keys(args).length) {
+ remainder.forEach(([key, value]) => {
+ a.pushContainer('properties', t.objectProperty(t.identifier(key), value));
+ });
+ }
+ }
+ }
+ } else {
+ path.unshiftContainer(
+ 'properties',
+ t.objectProperty(
+ t.identifier('args'),
+ t.objectExpression(
+ Object.entries(args).map(([key, value]) => t.objectProperty(t.identifier(key), value))
+ )
+ )
+ );
+ }
+ },
+
+ noScope: true,
+ });
+};
diff --git a/code/lib/core-server/src/utils/save-story/utils.ts b/code/lib/core-server/src/utils/save-story/utils.ts
new file mode 100644
index 000000000000..490cd212d884
--- /dev/null
+++ b/code/lib/core-server/src/utils/save-story/utils.ts
@@ -0,0 +1 @@
+export class SaveStoryError extends Error {}
diff --git a/code/lib/core-server/src/utils/save-story/valueToAST.ts b/code/lib/core-server/src/utils/save-story/valueToAST.ts
new file mode 100644
index 000000000000..f09dd7b65a64
--- /dev/null
+++ b/code/lib/core-server/src/utils/save-story/valueToAST.ts
@@ -0,0 +1,44 @@
+import * as t from '@babel/types';
+import * as babylon from '@babel/parser';
+
+export function valueToAST(literal: T): any {
+ if (literal === null) {
+ return t.nullLiteral();
+ }
+ switch (typeof literal) {
+ case 'function':
+ const ast = babylon.parse(literal.toString(), {
+ allowReturnOutsideFunction: true,
+ allowSuperOutsideMethod: true,
+ });
+
+ // @ts-expect-error (it's the contents of the function, it's an expression, trust me)
+ return ast.program.body[0]?.expression;
+
+ case 'number':
+ return t.numericLiteral(literal);
+ case 'string':
+ return t.stringLiteral(literal);
+ case 'boolean':
+ return t.booleanLiteral(literal);
+ case 'undefined':
+ return t.identifier('undefined');
+ default:
+ if (Array.isArray(literal)) {
+ return t.arrayExpression(literal.map(valueToAST));
+ }
+ return t.objectExpression(
+ Object.keys(literal)
+ .filter((k) => {
+ // @ts-expect-error (it's a completely unknown object)
+ const value = literal[k];
+ return typeof value !== 'undefined';
+ })
+ .map((k) => {
+ // @ts-expect-error (it's a completely unknown object)
+ const value = literal[k];
+ return t.objectProperty(t.stringLiteral(k), valueToAST(value));
+ })
+ );
+ }
+}
diff --git a/code/lib/core-server/src/utils/search-files.test.ts b/code/lib/core-server/src/utils/search-files.test.ts
index 6aaec136df74..c7f7ab51d388 100644
--- a/code/lib/core-server/src/utils/search-files.test.ts
+++ b/code/lib/core-server/src/utils/search-files.test.ts
@@ -66,6 +66,28 @@ describe('search-files', () => {
expect(files).toEqual(['src/commonjs-module.js']);
});
+ it('should respect glob but also the allowed file extensions', async (t) => {
+ const files = await searchFiles({
+ searchQuery: '**/*',
+ cwd: path.join(__dirname, '__search-files-tests__'),
+ });
+
+ expect(files).toEqual([
+ 'src/commonjs-module-default.js',
+ 'src/commonjs-module.js',
+ 'src/es-module.js',
+ 'src/no-export.js',
+ 'src/file-extensions/extension.cjs',
+ 'src/file-extensions/extension.cts',
+ 'src/file-extensions/extension.js',
+ 'src/file-extensions/extension.jsx',
+ 'src/file-extensions/extension.mjs',
+ 'src/file-extensions/extension.mts',
+ 'src/file-extensions/extension.ts',
+ 'src/file-extensions/extension.tsx',
+ ]);
+ });
+
it('should ignore node_modules', async (t) => {
const files = await searchFiles({
searchQuery: 'file-in-common.js',
@@ -75,6 +97,15 @@ describe('search-files', () => {
expect(files).toEqual([]);
});
+ it('should ignore story files', async (t) => {
+ const files = await searchFiles({
+ searchQuery: 'es-module.stories.js',
+ cwd: path.join(__dirname, '__search-files-tests__'),
+ });
+
+ expect(files).toEqual([]);
+ });
+
it('should not return files outside of project root', async (t) => {
await expect(() =>
searchFiles({
diff --git a/code/lib/core-server/src/utils/search-files.ts b/code/lib/core-server/src/utils/search-files.ts
index b6f1bd89ab25..98557584c4fa 100644
--- a/code/lib/core-server/src/utils/search-files.ts
+++ b/code/lib/core-server/src/utils/search-files.ts
@@ -3,7 +3,15 @@ export type SearchResult = Array;
/**
* File extensions that should be searched for
*/
-const fileExtensions = ['js', 'mjs', 'cjs', 'jsx', 'mts', 'ts', 'tsx', 'cts'];
+const FILE_EXTENSIONS = ['js', 'mjs', 'cjs', 'jsx', 'mts', 'ts', 'tsx', 'cts'];
+
+const IGNORED_FILES = [
+ '**/node_modules/**',
+ '**/*.spec.*',
+ '**/*.test.*',
+ '**/*.stories.*',
+ '**/storybook-static/**',
+];
/**
* Search for files in a directory that match the search query
@@ -15,9 +23,13 @@ const fileExtensions = ['js', 'mjs', 'cjs', 'jsx', 'mts', 'ts', 'tsx', 'cts'];
export async function searchFiles({
searchQuery,
cwd,
+ ignoredFiles = IGNORED_FILES,
+ fileExtensions = FILE_EXTENSIONS,
}: {
searchQuery: string;
cwd: string;
+ ignoredFiles?: string[];
+ fileExtensions?: string[];
}): Promise {
// Dynamically import globby because it is a pure ESM module
const { globby, isDynamicPattern } = await import('globby');
@@ -38,11 +50,14 @@ export async function searchFiles({
];
const entries = await globby(globbedSearchQuery, {
- ignore: ['**/node_modules/**', '**/*.spec.*', '**/*.test.*'],
+ ignore: ignoredFiles,
gitignore: true,
+ caseSensitiveMatch: false,
cwd,
objectMode: true,
});
- return entries.map((entry) => entry.path);
+ return entries
+ .map((entry) => entry.path)
+ .filter((entry) => fileExtensions.some((ext) => entry.endsWith(`.${ext}`)));
}
diff --git a/code/lib/core-server/src/utils/whats-new.ts b/code/lib/core-server/src/utils/whats-new.ts
new file mode 100644
index 000000000000..3392c93d201f
--- /dev/null
+++ b/code/lib/core-server/src/utils/whats-new.ts
@@ -0,0 +1,119 @@
+import fs from 'fs-extra';
+import { logger } from '@storybook/node-logger';
+import { telemetry } from '@storybook/telemetry';
+import { findConfigFile } from '@storybook/core-common';
+import type { CoreConfig, Options } from '@storybook/types';
+import { printConfig, readConfig } from '@storybook/csf-tools';
+import fetch from 'node-fetch';
+import type { Channel } from '@storybook/channels';
+import type { WhatsNewCache, WhatsNewData } from '@storybook/core-events';
+import {
+ REQUEST_WHATS_NEW_DATA,
+ RESULT_WHATS_NEW_DATA,
+ TELEMETRY_ERROR,
+ SET_WHATS_NEW_CACHE,
+ TOGGLE_WHATS_NEW_NOTIFICATIONS,
+} from '@storybook/core-events';
+import invariant from 'tiny-invariant';
+import { sendTelemetryError } from '../withTelemetry';
+
+export type OptionsWithRequiredCache = Exclude & Required>;
+
+// Grabbed from the implementation: https://github.com/storybookjs/dx-functions/blob/main/netlify/functions/whats-new.ts
+export type WhatsNewResponse = {
+ title: string;
+ url: string;
+ blogUrl?: string;
+ publishedAt: string;
+ excerpt: string;
+};
+
+const WHATS_NEW_CACHE = 'whats-new-cache';
+const WHATS_NEW_URL = 'https://storybook.js.org/whats-new/v1';
+
+export function initializeWhatsNew(
+ channel: Channel,
+ options: OptionsWithRequiredCache,
+ coreOptions: CoreConfig
+) {
+ channel.on(SET_WHATS_NEW_CACHE, async (data: WhatsNewCache) => {
+ const cache: WhatsNewCache = await options.cache.get(WHATS_NEW_CACHE).catch((e) => {
+ logger.verbose(e);
+ return {};
+ });
+ await options.cache.set(WHATS_NEW_CACHE, { ...cache, ...data });
+ });
+
+ channel.on(REQUEST_WHATS_NEW_DATA, async () => {
+ try {
+ const post = (await fetch(WHATS_NEW_URL).then(async (response) => {
+ if (response.ok) return response.json();
+ // eslint-disable-next-line @typescript-eslint/no-throw-literal
+ throw response;
+ })) as WhatsNewResponse;
+
+ const configFileName = findConfigFile('main', options.configDir);
+ if (!configFileName) {
+ throw new Error(`unable to find storybook main file in ${options.configDir}`);
+ }
+ const main = await readConfig(configFileName);
+ const disableWhatsNewNotifications = main.getFieldValue([
+ 'core',
+ 'disableWhatsNewNotifications',
+ ]);
+
+ const cache: WhatsNewCache = (await options.cache.get(WHATS_NEW_CACHE)) ?? {};
+ const data = {
+ ...post,
+ status: 'SUCCESS',
+ postIsRead: post.url === cache.lastReadPost,
+ showNotification: post.url !== cache.lastDismissedPost && post.url !== cache.lastReadPost,
+ disableWhatsNewNotifications,
+ } satisfies WhatsNewData;
+ channel.emit(RESULT_WHATS_NEW_DATA, { data });
+ } catch (e) {
+ logger.verbose(e instanceof Error ? e.message : String(e));
+ channel.emit(RESULT_WHATS_NEW_DATA, {
+ data: { status: 'ERROR' } satisfies WhatsNewData,
+ });
+ }
+ });
+
+ channel.on(
+ TOGGLE_WHATS_NEW_NOTIFICATIONS,
+ async ({ disableWhatsNewNotifications }: { disableWhatsNewNotifications: boolean }) => {
+ const isTelemetryEnabled = coreOptions.disableTelemetry !== true;
+ try {
+ const mainPath = findConfigFile('main', options.configDir);
+ invariant(mainPath, `unable to find storybook main file in ${options.configDir}`);
+ const main = await readConfig(mainPath);
+ main.setFieldValue(['core', 'disableWhatsNewNotifications'], disableWhatsNewNotifications);
+ await fs.writeFile(mainPath, printConfig(main).code);
+ if (isTelemetryEnabled) {
+ await telemetry('core-config', { disableWhatsNewNotifications });
+ }
+ } catch (error) {
+ invariant(error instanceof Error);
+ if (isTelemetryEnabled) {
+ await sendTelemetryError(error, 'core-config', {
+ cliOptions: options,
+ presetOptions: { ...options, corePresets: [], overridePresets: [] },
+ skipPrompt: true,
+ });
+ }
+ }
+ }
+ );
+
+ channel.on(TELEMETRY_ERROR, async (error) => {
+ const isTelemetryEnabled = coreOptions.disableTelemetry !== true;
+
+ if (isTelemetryEnabled) {
+ await sendTelemetryError(error, 'browser', {
+ cliOptions: options,
+ presetOptions: { ...options, corePresets: [], overridePresets: [] },
+ skipPrompt: true,
+ });
+ }
+ });
+}
diff --git a/code/lib/csf-tools/package.json b/code/lib/csf-tools/package.json
index ea2fd24cfe15..219ac1c3788e 100644
--- a/code/lib/csf-tools/package.json
+++ b/code/lib/csf-tools/package.json
@@ -42,11 +42,11 @@
"prep": "node --loader ../../../scripts/node_modules/esbuild-register/loader.js -r ../../../scripts/node_modules/esbuild-register/register.js ../../../scripts/prepare/bundle.ts"
},
"dependencies": {
- "@babel/generator": "^7.23.0",
- "@babel/parser": "^7.23.0",
- "@babel/traverse": "^7.23.2",
- "@babel/types": "^7.23.0",
- "@storybook/csf": "^0.1.2",
+ "@babel/generator": "^7.24.4",
+ "@babel/parser": "^7.24.4",
+ "@babel/traverse": "^7.24.1",
+ "@babel/types": "^7.24.0",
+ "@storybook/csf": "^0.1.6",
"@storybook/types": "workspace:*",
"fs-extra": "^11.1.0",
"recast": "^0.23.5",
diff --git a/code/lib/docs-tools/package.json b/code/lib/docs-tools/package.json
index 730a861c26e0..5af72a730ae4 100644
--- a/code/lib/docs-tools/package.json
+++ b/code/lib/docs-tools/package.json
@@ -45,6 +45,7 @@
},
"dependencies": {
"@storybook/core-common": "workspace:*",
+ "@storybook/core-events": "workspace:*",
"@storybook/preview-api": "workspace:*",
"@storybook/types": "workspace:*",
"@types/doctrine": "^0.0.3",
@@ -53,7 +54,7 @@
"lodash": "^4.17.21"
},
"devDependencies": {
- "@babel/preset-react": "^7.23.3",
+ "@babel/preset-react": "^7.24.1",
"babel-plugin-react-docgen": "4.2.1",
"require-from-string": "^2.0.2",
"typescript": "^5.3.2"
diff --git a/code/lib/docs-tools/src/argTypes/convert/flow/convert.ts b/code/lib/docs-tools/src/argTypes/convert/flow/convert.ts
index b950be171bee..a6906589da75 100644
--- a/code/lib/docs-tools/src/argTypes/convert/flow/convert.ts
+++ b/code/lib/docs-tools/src/argTypes/convert/flow/convert.ts
@@ -1,3 +1,4 @@
+import { UnknownArgTypesError } from '@storybook/core-events/preview-errors';
import type { SBType } from '@storybook/types';
import type { FlowType, FlowSigType, FlowLiteralType } from './types';
@@ -18,7 +19,7 @@ const convertSig = (type: FlowSigType) => {
value: values,
};
default:
- throw new Error(`Unknown: ${type}`);
+ throw new UnknownArgTypesError({ type: type, language: 'Flow' });
}
};
diff --git a/code/lib/docs-tools/src/argTypes/convert/index.ts b/code/lib/docs-tools/src/argTypes/convert/index.ts
index 60db96fe65d6..812fcf517896 100644
--- a/code/lib/docs-tools/src/argTypes/convert/index.ts
+++ b/code/lib/docs-tools/src/argTypes/convert/index.ts
@@ -7,9 +7,14 @@ import { convert as propTypesConvert } from './proptypes';
export const convert = (docgenInfo: DocgenInfo) => {
const { type, tsType, flowType } = docgenInfo;
- if (type != null) return propTypesConvert(type);
- if (tsType != null) return tsConvert(tsType as TSType);
- if (flowType != null) return flowConvert(flowType as FlowType);
+ try {
+ if (type != null) return propTypesConvert(type);
+ if (tsType != null) return tsConvert(tsType as TSType);
+ if (flowType != null) return flowConvert(flowType as FlowType);
+ } catch (err) {
+ // if we can't convert the type, we'll just return null to fallback to a simple summary, and provide the error to the user
+ console.error(err);
+ }
return null;
};
diff --git a/code/lib/docs-tools/src/argTypes/convert/typescript/convert.ts b/code/lib/docs-tools/src/argTypes/convert/typescript/convert.ts
index 95436dfcb567..def09e92a305 100644
--- a/code/lib/docs-tools/src/argTypes/convert/typescript/convert.ts
+++ b/code/lib/docs-tools/src/argTypes/convert/typescript/convert.ts
@@ -1,3 +1,4 @@
+import { UnknownArgTypesError } from '@storybook/core-events/preview-errors';
import type { SBType } from '@storybook/types';
import type { TSType, TSSigType } from './types';
import { parseLiteral } from '../utils';
@@ -16,7 +17,7 @@ const convertSig = (type: TSSigType) => {
value: values,
};
default:
- throw new Error(`Unknown: ${type}`);
+ throw new UnknownArgTypesError({ type, language: 'Typescript' });
}
};
diff --git a/code/lib/manager-api/package.json b/code/lib/manager-api/package.json
index c4f0d14e6257..0a68b927b820 100644
--- a/code/lib/manager-api/package.json
+++ b/code/lib/manager-api/package.json
@@ -47,7 +47,7 @@
"@storybook/channels": "workspace:*",
"@storybook/client-logger": "workspace:*",
"@storybook/core-events": "workspace:*",
- "@storybook/csf": "^0.1.2",
+ "@storybook/csf": "^0.1.6",
"@storybook/global": "^5.0.0",
"@storybook/icons": "^1.2.5",
"@storybook/router": "workspace:*",
diff --git a/code/lib/manager-api/src/index.tsx b/code/lib/manager-api/src/index.tsx
index fbb8b74a6420..4e946fa5d618 100644
--- a/code/lib/manager-api/src/index.tsx
+++ b/code/lib/manager-api/src/index.tsx
@@ -69,6 +69,7 @@ import type { ModuleFn } from './lib/types';
import { types } from './lib/addons';
+export * from './lib/request-response';
export * from './lib/shortcut';
const { ActiveTabs } = layout;
@@ -478,11 +479,13 @@ export function useAddonState(addonId: string, defaultState?: S) {
return useSharedState(addonId, defaultState);
}
-export function useArgs(): [Args, (newArgs: Args) => void, (argNames?: string[]) => void] {
+export function useArgs(): [Args, (newArgs: Args) => void, (argNames?: string[]) => void, Args] {
const { getCurrentStoryData, updateStoryArgs, resetStoryArgs } = useStorybookApi();
const data = getCurrentStoryData();
const args = data?.type === 'story' ? data.args : {};
+ const initialArgs = data?.type === 'story' ? data.initialArgs : {};
+
const updateArgs = useCallback(
(newArgs: Args) => updateStoryArgs(data as API_StoryEntry, newArgs),
[data, updateStoryArgs]
@@ -492,7 +495,7 @@ export function useArgs(): [Args, (newArgs: Args) => void, (argNames?: string[])
[data, resetStoryArgs]
);
- return [args!, updateArgs, resetArgs];
+ return [args!, updateArgs, resetArgs, initialArgs!];
}
export function useGlobals(): [Args, (newGlobals: Args) => void] {
diff --git a/code/lib/manager-api/src/lib/request-response.ts b/code/lib/manager-api/src/lib/request-response.ts
new file mode 100644
index 000000000000..bed05f576461
--- /dev/null
+++ b/code/lib/manager-api/src/lib/request-response.ts
@@ -0,0 +1,51 @@
+import type { Channel } from '@storybook/channels';
+import type { RequestData, ResponseData } from '@storybook/core-events';
+
+export class RequestResponseError | void> extends Error {
+ payload: Payload | undefined = undefined;
+
+ constructor(message: string, payload?: Payload) {
+ super(message);
+ this.payload = payload;
+ }
+}
+
+// eslint-disable-next-line @typescript-eslint/naming-convention
+export const experimental_requestResponse = <
+ RequestPayload,
+ ResponsePayload = void,
+ CreateNewStoryErrorPayload extends Record | void = void,
+>(
+ channel: Channel,
+ requestEvent: string,
+ responseEvent: string,
+ payload: RequestPayload,
+ timeout = 5000
+): Promise => {
+ let timeoutId: NodeJS.Timeout;
+
+ return new Promise((resolve, reject) => {
+ const request: RequestData = {
+ id: Math.random().toString(16).slice(2),
+ payload,
+ };
+
+ const responseHandler = (
+ response: ResponseData
+ ) => {
+ if (response.id !== request.id) return;
+ clearTimeout(timeoutId);
+ channel.off(responseEvent, responseHandler);
+ if (response.success) resolve(response.payload);
+ else reject(new RequestResponseError(response.error, response.payload));
+ };
+
+ channel.emit(requestEvent, request);
+ channel.on(responseEvent, responseHandler);
+
+ timeoutId = setTimeout(() => {
+ channel.off(responseEvent, responseHandler);
+ reject(new RequestResponseError('Timed out waiting for response'));
+ }, timeout);
+ });
+};
diff --git a/code/lib/manager-api/src/modules/stories.ts b/code/lib/manager-api/src/modules/stories.ts
index 78c3307f179f..19ea80ce41f4 100644
--- a/code/lib/manager-api/src/modules/stories.ts
+++ b/code/lib/manager-api/src/modules/stories.ts
@@ -648,17 +648,20 @@ export const init: ModuleFn = ({
}
},
experimental_setFilter: async (id, filterFunction) => {
- const { internal_index: index } = store.getState();
await store.setState({ filters: { ...store.getState().filters, [id]: filterFunction } });
- if (index) {
- await api.setIndex(index);
+ const { internal_index: index } = store.getState();
- const refs = await fullAPI.getRefs();
- Object.entries(refs).forEach(([refId, { internal_index, ...ref }]) => {
- fullAPI.setRef(refId, { ...ref, storyIndex: internal_index }, true);
- });
+ if (!index) {
+ return;
}
+ // apply new filters by setting the index again
+ await api.setIndex(index);
+
+ const refs = await fullAPI.getRefs();
+ Object.entries(refs).forEach(([refId, { internal_index, ...ref }]) => {
+ fullAPI.setRef(refId, { ...ref, storyIndex: internal_index }, true);
+ });
},
};
@@ -879,6 +882,7 @@ export const init: ModuleFn = ({
},
init: async () => {
provider.channel?.on(STORY_INDEX_INVALIDATED, () => api.fetchIndex());
+
await api.fetchIndex();
},
};
diff --git a/code/lib/manager-api/src/modules/versions.ts b/code/lib/manager-api/src/modules/versions.ts
index a414c07fe073..8305523f2f0d 100644
--- a/code/lib/manager-api/src/modules/versions.ts
+++ b/code/lib/manager-api/src/modules/versions.ts
@@ -109,7 +109,7 @@ export const init: ModuleFn = ({ store }) => {
}
if (renderer && typeof global.STORYBOOK_RENDERER !== 'undefined') {
- const rendererName = (global.STORYBOOK_RENDERER as string).split('/').pop()?.toLowerCase();
+ const rendererName = global.STORYBOOK_RENDERER as string;
if (rendererName) {
url += `?renderer=${normalizeRendererName(rendererName)}`;
diff --git a/code/lib/preview-api/package.json b/code/lib/preview-api/package.json
index 8ade774be38a..36797a9265e2 100644
--- a/code/lib/preview-api/package.json
+++ b/code/lib/preview-api/package.json
@@ -47,7 +47,7 @@
"@storybook/channels": "workspace:*",
"@storybook/client-logger": "workspace:*",
"@storybook/core-events": "workspace:*",
- "@storybook/csf": "^0.1.2",
+ "@storybook/csf": "^0.1.6",
"@storybook/global": "^5.0.0",
"@storybook/types": "workspace:*",
"@types/qs": "^6.9.5",
diff --git a/code/lib/preview-api/src/Errors.stories.tsx b/code/lib/preview-api/src/Errors.stories.tsx
new file mode 100644
index 000000000000..41363ff5c566
--- /dev/null
+++ b/code/lib/preview-api/src/Errors.stories.tsx
@@ -0,0 +1,72 @@
+import React from 'react';
+import AnsiToHtml from 'ansi-to-html';
+import dedent from 'ts-dedent';
+
+const ansiConverter = new AnsiToHtml({
+ escapeXML: true,
+});
+
+const Component = ({ id, header, detail }: any) => {
+ const element = document.querySelector('.' + id);
+ if (!element) {
+ throw new Error('Element not found');
+ }
+
+ if (header) {
+ document.getElementById('error-message')!.innerHTML = ansiConverter.toHtml(header);
+ }
+
+ if (detail) {
+ document.getElementById('error-stack')!.innerHTML = ansiConverter.toHtml(detail);
+ }
+
+ // remove the ids, otherwise chromatic will assume the story failed to render
+ const content = element.outerHTML.replace('error-message', '').replace('error-stack', '');
+
+ // remove the content, otherwise chromatic will assume the story failed to render
+ document.getElementById('error-message')!.innerHTML = '';
+ document.getElementById('error-stack')!.innerHTML = '';
+
+ return (
+
+ );
+};
+
+export default {
+ component: Component,
+ parameters: {
+ layout: 'fullscreen',
+ theme: 'light',
+ },
+ title: 'Errors',
+ args: {
+ id: 'sb-errordisplay',
+ },
+};
+
+export const MyError = {
+ args: {
+ header: `FAIL is not defined`,
+ detail: dedent`
+ ReferenceError: FAIL is not defined
+ at Constraint.execute (the-best-file.js:525:2)
+ at Constraint.recalculate (the-best-file.js:424:21)
+ at Planner.addPropagate (the-best-file.js:701:6)
+ at Constraint.satisfy (the-best-file.js:184:15)
+ at Planner.incrementalAdd (the-best-file.js:591:21)
+ at Constraint.addConstraint (the-best-file.js:162:10)
+ at Constraint.BinaryConstraint (the-best-file.js:346:7)
+ at Constraint.EqualityConstraint (the-best-file.js:515:38)
+ at chainTest (the-best-file.js:807:6)
+ at deltaBlue (the-best-file.js:879:2)`,
+ },
+};
+
+export const Missing = {
+ args: {
+ id: 'sb-nopreview',
+ },
+};
diff --git a/code/lib/preview-api/src/modules/preview-web/Preview.tsx b/code/lib/preview-api/src/modules/preview-web/Preview.tsx
index 29ea71045949..ee4524c32ecf 100644
--- a/code/lib/preview-api/src/modules/preview-web/Preview.tsx
+++ b/code/lib/preview-api/src/modules/preview-web/Preview.tsx
@@ -1,6 +1,14 @@
import { global } from '@storybook/global';
import { deprecate, logger } from '@storybook/client-logger';
+import type {
+ ArgTypesRequestPayload,
+ ArgTypesResponsePayload,
+ RequestData,
+ ResponseData,
+} from '@storybook/core-events';
import {
+ ARGTYPES_INFO_REQUEST,
+ ARGTYPES_INFO_RESPONSE,
CONFIG_ERROR,
FORCE_REMOUNT,
FORCE_RE_RENDER,
@@ -129,6 +137,7 @@ export class Preview {
this.channel.on(STORY_INDEX_INVALIDATED, this.onStoryIndexChanged.bind(this));
this.channel.on(UPDATE_GLOBALS, this.onUpdateGlobals.bind(this));
this.channel.on(UPDATE_STORY_ARGS, this.onUpdateArgs.bind(this));
+ this.channel.on(ARGTYPES_INFO_REQUEST, this.onRequestArgTypesInfo.bind(this));
this.channel.on(RESET_STORY_ARGS, this.onResetArgs.bind(this));
this.channel.on(FORCE_RE_RENDER, this.onForceReRender.bind(this));
this.channel.on(FORCE_REMOUNT, this.onForceRemount.bind(this));
@@ -295,6 +304,25 @@ export class Preview {
});
}
+ async onRequestArgTypesInfo({ id, payload }: RequestData) {
+ try {
+ await this.storeInitializationPromise;
+ const story = await this.storyStoreValue?.loadStory(payload);
+ this.channel.emit(ARGTYPES_INFO_RESPONSE, {
+ id,
+ success: true,
+ payload: { argTypes: story?.argTypes || {} },
+ error: null,
+ } satisfies ResponseData);
+ } catch (e: any) {
+ this.channel.emit(ARGTYPES_INFO_RESPONSE, {
+ id,
+ success: false,
+ error: e?.message,
+ } satisfies ResponseData);
+ }
+ }
+
async onResetArgs({ storyId, argNames }: { storyId: string; argNames?: string[] }) {
if (!this.storyStoreValue)
throw new CalledPreviewMethodBeforeInitializationError({ methodName: 'onResetArgs' });
diff --git a/code/lib/preview-api/src/modules/preview-web/PreviewWeb.test.ts b/code/lib/preview-api/src/modules/preview-web/PreviewWeb.test.ts
index e20af13752a6..f71c3ab31eaf 100644
--- a/code/lib/preview-api/src/modules/preview-web/PreviewWeb.test.ts
+++ b/code/lib/preview-api/src/modules/preview-web/PreviewWeb.test.ts
@@ -208,15 +208,19 @@ describe('PreviewWeb', () => {
one: 1,
});
});
- it('updates args from the URL', async () => {
+
+ it('prepares story with args from the URL', async () => {
document.location.search = '?id=component-one--a&args=foo:url';
await createAndRenderPreview();
- expect(mockChannel.emit).toHaveBeenCalledWith(STORY_ARGS_UPDATED, {
- storyId: 'component-one--a',
- args: { foo: 'url', one: 1 },
- });
+ expect(mockChannel.emit).toHaveBeenCalledWith(
+ STORY_PREPARED,
+ expect.objectContaining({
+ id: 'component-one--a',
+ args: { foo: 'url', one: 1 },
+ })
+ );
});
it('allows async getProjectAnnotations', async () => {
@@ -907,64 +911,74 @@ describe('PreviewWeb', () => {
});
describe('while story is still rendering', () => {
- it('runs loaders again', async () => {
+ it('runs loaders again after renderToCanvas is done', async () => {
+ // Arrange - set up a gate to control when the loaders run
const [loadersRanGate, openLoadersRanGate] = createGate();
const [blockLoadersGate, openBlockLoadersGate] = createGate();
document.location.search = '?id=component-one--a';
- componentOneExports.default.loaders[0].mockImplementationOnce(async () => {
+ componentOneExports.default.loaders[0].mockImplementationOnce(async (input) => {
openLoadersRanGate();
return blockLoadersGate;
});
+ // Act - render the first time
await new PreviewWeb(importFn, getProjectAnnotations).ready();
await loadersRanGate;
+ // Assert - loader to be called the first time
+ expect(componentOneExports.default.loaders[0]).toHaveBeenCalledOnce();
expect(componentOneExports.default.loaders[0]).toHaveBeenCalledWith(
expect.objectContaining({
args: { foo: 'a', one: 'mapped-1' },
})
);
- componentOneExports.default.loaders[0].mockClear();
+ // Act - update the args (while loader is still running)
emitter.emit(UPDATE_STORY_ARGS, {
storyId: 'component-one--a',
updatedArgs: { new: 'arg' },
});
- await waitForRender();
- expect(componentOneExports.default.loaders[0]).toHaveBeenCalledWith(
- expect.objectContaining({
- args: { foo: 'a', new: 'arg', one: 'mapped-1' },
- })
- );
+ // Arrange - open the gate to let the loader finish and wait for render
+ openBlockLoadersGate({ l: 8 });
+ await waitForRender();
- // Story gets rendered with updated args
- expect(projectAnnotations.renderToCanvas).toHaveBeenCalledTimes(1);
+ // Assert - renderToCanvas to be called the first time with initial args
+ expect(projectAnnotations.renderToCanvas).toHaveBeenCalledOnce();
expect(projectAnnotations.renderToCanvas).toHaveBeenCalledWith(
expect.objectContaining({
- forceRemount: true, // Wasn't yet rendered so we need to force remount
+ forceRemount: true,
storyContext: expect.objectContaining({
- loaded: { l: 7 }, // This is the value returned by the *second* loader call
+ loaded: { l: 8 }, // This is the value returned by the *first* loader call
args: { foo: 'a', new: 'arg', one: 'mapped-1' },
}),
}),
'story-element'
);
+ // Assert - loaders are not run again yet
+ expect(componentOneExports.default.loaders[0]).toHaveBeenCalledOnce();
- // Now let the first loader call resolve
+ // Arrange - wait for loading and rendering to finish a second time
mockChannel.emit.mockClear();
- projectAnnotations.renderToCanvas.mockClear();
- openBlockLoadersGate({ l: 8 });
await waitForRender();
+ // Assert - loader is called a second time with updated args
+ await vi.waitFor(() => {
+ expect(projectAnnotations.renderToCanvas).toHaveBeenCalledTimes(2);
+ expect(componentOneExports.default.loaders[0]).toHaveBeenCalledWith(
+ expect.objectContaining({
+ args: { foo: 'a', new: 'arg', one: 'mapped-1' },
+ })
+ );
+ });
- // Now the first call comes through, but picks up the new args
- // Note this isn't a particularly realistic case (the second loader being quicker than the first)
- expect(projectAnnotations.renderToCanvas).toHaveBeenCalledTimes(1);
+ // Assert - renderToCanvas is called a second time with updated args
+ expect(projectAnnotations.renderToCanvas).toHaveBeenCalledTimes(2);
expect(projectAnnotations.renderToCanvas).toHaveBeenCalledWith(
expect.objectContaining({
+ forceRemount: false,
storyContext: expect.objectContaining({
- loaded: { l: 8 },
+ loaded: { l: 7 }, // This is the value returned by the *second* loader call
args: { foo: 'a', new: 'arg', one: 'mapped-1' },
}),
}),
@@ -972,7 +986,7 @@ describe('PreviewWeb', () => {
);
});
- it('renders a second time if renderToCanvas is running', async () => {
+ it('renders a second time after the already running renderToCanvas is done', async () => {
const [gate, openGate] = createGate();
document.location.search = '?id=component-one--a';
@@ -986,11 +1000,9 @@ describe('PreviewWeb', () => {
updatedArgs: { new: 'arg' },
});
- // Now let the renderToCanvas call resolve
+ // Now let the first renderToCanvas call resolve
openGate();
- await waitForRender();
-
- expect(projectAnnotations.renderToCanvas).toHaveBeenCalledTimes(2);
+ expect(projectAnnotations.renderToCanvas).toHaveBeenCalledTimes(1);
expect(projectAnnotations.renderToCanvas).toHaveBeenCalledWith(
expect.objectContaining({
forceRemount: true,
@@ -1001,39 +1013,14 @@ describe('PreviewWeb', () => {
}),
'story-element'
);
- expect(projectAnnotations.renderToCanvas).toHaveBeenCalledWith(
- expect.objectContaining({
- forceRemount: false,
- storyContext: expect.objectContaining({
- loaded: { l: 7 },
- args: { foo: 'a', new: 'arg', one: 'mapped-1' },
- }),
- }),
- 'story-element'
- );
- });
- it('works if it is called directly from inside non async renderToCanvas', async () => {
- document.location.search = '?id=component-one--a';
- projectAnnotations.renderToCanvas.mockImplementation(() => {
- emitter.emit(UPDATE_STORY_ARGS, {
- storyId: 'component-one--a',
- updatedArgs: { new: 'arg' },
- });
- });
- await createAndRenderPreview();
+ // Wait for the second render to finish
+ mockChannel.emit.mockClear();
+ await waitForRender();
+ await waitForRenderPhase('rendering');
+ // Expect the second render to have the updated args
expect(projectAnnotations.renderToCanvas).toHaveBeenCalledTimes(2);
- expect(projectAnnotations.renderToCanvas).toHaveBeenCalledWith(
- expect.objectContaining({
- forceRemount: true,
- storyContext: expect.objectContaining({
- loaded: { l: 7 },
- args: { foo: 'a', one: 'mapped-1' },
- }),
- }),
- 'story-element'
- );
expect(projectAnnotations.renderToCanvas).toHaveBeenCalledWith(
expect.objectContaining({
forceRemount: false,
@@ -1502,6 +1489,9 @@ describe('PreviewWeb', () => {
openGate();
await waitForRenderPhase('aborted');
+ // allow teardown to complete its retries
+ vi.runOnlyPendingTimers();
+
await waitForRenderPhase('rendering');
expect(projectAnnotations.renderToCanvas).toHaveBeenCalledTimes(2);
@@ -2141,39 +2131,6 @@ describe('PreviewWeb', () => {
window.location = { ...originalLocation, reload: originalLocation.reload };
});
- it('stops initial story after loaders if running', async () => {
- const [gate, openGate] = createGate();
- componentOneExports.default.loaders[0].mockImplementationOnce(async () => gate);
-
- document.location.search = '?id=component-one--a';
- await new PreviewWeb(importFn, getProjectAnnotations).ready();
- await waitForRenderPhase('loading');
-
- emitter.emit(SET_CURRENT_STORY, {
- storyId: 'component-one--b',
- viewMode: 'story',
- });
- await waitForSetCurrentStory();
- await waitForRender();
-
- // Now let the loader resolve
- openGate({ l: 8 });
- await waitForRender();
-
- // Story gets rendered with updated args
- expect(projectAnnotations.renderToCanvas).toHaveBeenCalledTimes(1);
- expect(projectAnnotations.renderToCanvas).toHaveBeenCalledWith(
- expect.objectContaining({
- forceRemount: true,
- storyContext: expect.objectContaining({
- id: 'component-one--b',
- loaded: { l: 7 },
- }),
- }),
- 'story-element'
- );
- });
-
it('aborts render for initial story', async () => {
const [gate, openGate] = createGate();
@@ -2731,6 +2688,60 @@ describe('PreviewWeb', () => {
});
});
+ describe('if called twice simultaneously', () => {
+ it('does not get renders confused', async () => {
+ const [blockImportFnGate, openBlockImportFnGate] = createGate();
+ const [importFnCalledGate, openImportFnCalledGate] = createGate();
+ const newImportFn = vi.fn(async (path) => {
+ openImportFnCalledGate();
+ await blockImportFnGate;
+ return importFn(path);
+ });
+
+ document.location.search = '?id=component-one--a';
+ const preview = await createAndRenderPreview();
+ mockChannel.emit.mockClear();
+
+ preview.onStoriesChanged({ importFn: newImportFn });
+ await importFnCalledGate;
+ preview.onStoriesChanged({ importFn });
+
+ openBlockImportFnGate();
+ await waitForRender();
+
+ expect(preview.storyRenders.length).toEqual(1);
+ });
+
+ it('renders the second importFn', async () => {
+ const [importGate, openImportGate] = createGate();
+ const [importedGate, openImportedGate] = createGate();
+ const secondImportFn = vi.fn(async (path) => {
+ openImportedGate();
+ await importGate;
+ return importFn(path);
+ });
+
+ const thirdImportFn = vi.fn(async (path) => {
+ openImportedGate();
+ await importGate;
+ return importFn(path);
+ });
+
+ document.location.search = '?id=component-one--a';
+ const preview = await createAndRenderPreview();
+ mockChannel.emit.mockClear();
+
+ preview.onStoriesChanged({ importFn: secondImportFn });
+ await importedGate;
+ preview.onStoriesChanged({ importFn: thirdImportFn });
+
+ openImportGate();
+ await waitForRender();
+
+ expect(thirdImportFn).toHaveBeenCalled();
+ });
+ });
+
describe('when the current story changes', () => {
const newComponentOneExports = merge({}, componentOneExports, {
a: { args: { foo: 'edited' } },
@@ -2798,20 +2809,6 @@ describe('PreviewWeb', () => {
});
});
- it('emits STORY_ARGS_UPDATED with new args', async () => {
- document.location.search = '?id=component-one--a';
- const preview = await createAndRenderPreview();
- mockChannel.emit.mockClear();
-
- preview.onStoriesChanged({ importFn: newImportFn });
- await waitForRender();
-
- expect(mockChannel.emit).toHaveBeenCalledWith(STORY_ARGS_UPDATED, {
- storyId: 'component-one--a',
- args: { foo: 'edited', one: 1 },
- });
- });
-
it('applies loaders with story context', async () => {
document.location.search = '?id=component-one--a';
const preview = await createAndRenderPreview();
@@ -3402,7 +3399,7 @@ describe('PreviewWeb', () => {
});
});
- it('emits SET_STORY_ARGS with new values', async () => {
+ it('emits SET_PREPARED with new args', async () => {
document.location.search = '?id=component-one--a';
const preview = await createAndRenderPreview();
@@ -3410,10 +3407,13 @@ describe('PreviewWeb', () => {
preview.onGetProjectAnnotationsChanged({ getProjectAnnotations: newGetProjectAnnotations });
await waitForRender();
- expect(mockChannel.emit).toHaveBeenCalledWith(STORY_ARGS_UPDATED, {
- storyId: 'component-one--a',
- args: { foo: 'a', one: 1, global: 'added' },
- });
+ expect(mockChannel.emit).toHaveBeenCalledWith(
+ STORY_PREPARED,
+ expect.objectContaining({
+ id: 'component-one--a',
+ args: { foo: 'a', one: 1, global: 'added' },
+ })
+ );
});
it('calls renderToCanvas teardown', async () => {
diff --git a/code/lib/preview-api/src/modules/preview-web/PreviewWithSelection.tsx b/code/lib/preview-api/src/modules/preview-web/PreviewWithSelection.tsx
index 1095ebe14620..f4c15e000d14 100644
--- a/code/lib/preview-api/src/modules/preview-web/PreviewWithSelection.tsx
+++ b/code/lib/preview-api/src/modules/preview-web/PreviewWithSelection.tsx
@@ -5,7 +5,6 @@ import {
PRELOAD_ENTRIES,
PREVIEW_KEYDOWN,
SET_CURRENT_STORY,
- STORY_ARGS_UPDATED,
STORY_CHANGED,
STORY_ERRORED,
STORY_MISSING,
@@ -353,12 +352,8 @@ export class PreviewWithSelection extends Preview extends Preview , (_?: any) => void] => {
- let openGate = (_?: any) => {};
- const gate = new Promise((resolve) => {
+const createGate = (): [Promise, () => void] => {
+ let openGate = () => {};
+ const gate = new Promise((resolve) => {
openGate = resolve;
});
return [gate, openGate];
};
+const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
-describe('StoryRender', () => {
- it('throws PREPARE_ABORTED if torndown during prepare', async () => {
- const [importGate, openImportGate] = createGate();
- const mockStore = {
- loadStory: vi.fn(async () => {
- await importGate;
- return {};
- }),
- cleanupStory: vi.fn(),
- };
-
- const render = new StoryRender(
- new Channel({}),
- mockStore as unknown as StoryStore,
- vi.fn(),
- {} as any,
- entry.id,
- 'story'
- );
-
- const preparePromise = render.prepare();
-
- render.teardown();
-
- openImportGate();
-
- await expect(preparePromise).rejects.toThrowError(PREPARE_ABORTED);
- });
+window.location = { reload: vi.fn() } as any;
+describe('StoryRender', () => {
it('does run play function if passed autoplay=true', async () => {
const story = {
id: 'id',
@@ -59,6 +34,7 @@ describe('StoryRender', () => {
name: 'name',
tags: [],
applyLoaders: vi.fn(),
+ applyBeforeEach: vi.fn(),
unboundStoryFn: vi.fn(),
playFunction: vi.fn(),
prepareContext: vi.fn(),
@@ -66,7 +42,7 @@ describe('StoryRender', () => {
const render = new StoryRender(
new Channel({}),
- { getStoryContext: () => ({}) } as any,
+ { getStoryContext: () => ({}), addCleanupCallbacks: vi.fn() } as any,
vi.fn() as any,
{} as any,
entry.id,
@@ -86,6 +62,7 @@ describe('StoryRender', () => {
name: 'name',
tags: [],
applyLoaders: vi.fn(),
+ applyBeforeEach: vi.fn(),
unboundStoryFn: vi.fn(),
playFunction: vi.fn(),
prepareContext: vi.fn(),
@@ -93,7 +70,7 @@ describe('StoryRender', () => {
const render = new StoryRender(
new Channel({}),
- { getStoryContext: () => ({}) } as any,
+ { getStoryContext: () => ({}), addCleanupCallbacks: vi.fn() } as any,
vi.fn() as any,
{} as any,
entry.id,
@@ -105,4 +82,270 @@ describe('StoryRender', () => {
await render.renderToElement({} as any);
expect(story.playFunction).not.toHaveBeenCalled();
});
+
+ it('only rerenders once when triggered multiple times while pending', async () => {
+ // Arrange - setup StoryRender and async gate blocking applyLoaders
+ const [loaderGate, openLoaderGate] = createGate();
+ const story = {
+ id: 'id',
+ title: 'title',
+ name: 'name',
+ tags: [],
+ applyLoaders: vi.fn(() => loaderGate),
+ applyBeforeEach: vi.fn(),
+ unboundStoryFn: vi.fn(),
+ playFunction: vi.fn(),
+ prepareContext: vi.fn(),
+ };
+ const store = {
+ getStoryContext: () => ({}),
+ cleanupStory: vi.fn(),
+ addCleanupCallbacks: vi.fn(),
+ };
+ const renderToScreen = vi.fn();
+ const render = new StoryRender(
+ new Channel({}),
+ store as any,
+ renderToScreen,
+ {} as any,
+ entry.id,
+ 'story',
+ { autoplay: true },
+ story as any
+ );
+ // Arrange - render (blocked by loaders)
+ render.renderToElement({} as any);
+ expect(story.applyLoaders).toHaveBeenCalledOnce();
+ expect(render.phase).toBe('loading');
+
+ // Act - rerender 3x
+ render.rerender();
+ render.rerender();
+ render.rerender();
+
+ // Assert - still loading, not yet rendered
+ expect(story.applyLoaders).toHaveBeenCalledOnce();
+ expect(render.phase).toBe('loading');
+ expect(renderToScreen).not.toHaveBeenCalled();
+
+ // Act - finish loading
+ openLoaderGate();
+
+ // Assert - loaded and rendered twice, played once
+ await vi.waitFor(async () => {
+ console.log(render.phase);
+ expect(story.applyLoaders).toHaveBeenCalledTimes(2);
+ expect(renderToScreen).toHaveBeenCalledTimes(2);
+ expect(story.playFunction).toHaveBeenCalledOnce();
+ });
+ });
+
+ describe('teardown', () => {
+ it('throws PREPARE_ABORTED if torndown during prepare', async () => {
+ const [importGate, openImportGate] = createGate();
+ const mockStore = {
+ loadStory: vi.fn(async () => {
+ await importGate;
+ return {};
+ }),
+ cleanupStory: vi.fn(),
+ };
+
+ const render = new StoryRender(
+ new Channel({}),
+ mockStore as unknown as StoryStore,
+ vi.fn(),
+ {} as any,
+ entry.id,
+ 'story'
+ );
+
+ const preparePromise = render.prepare();
+
+ render.teardown();
+
+ openImportGate();
+
+ await expect(preparePromise).rejects.toThrowError(PREPARE_ABORTED);
+ });
+
+ it('reloads the page when tearing down during loading', async () => {
+ // Arrange - setup StoryRender and async gate blocking applyLoaders
+ const [loaderGate] = createGate();
+ const story = {
+ id: 'id',
+ title: 'title',
+ name: 'name',
+ tags: [],
+ applyLoaders: vi.fn(() => loaderGate),
+ applyBeforeEach: vi.fn(),
+ unboundStoryFn: vi.fn(),
+ playFunction: vi.fn(),
+ prepareContext: vi.fn(),
+ };
+ const store = {
+ getStoryContext: () => ({}),
+ cleanupStory: vi.fn(),
+ addCleanupCallbacks: vi.fn(),
+ };
+ const render = new StoryRender(
+ new Channel({}),
+ store as any,
+ vi.fn() as any,
+ {} as any,
+ entry.id,
+ 'story',
+ { autoplay: true },
+ story as any
+ );
+
+ // Act - render (blocked by loaders), teardown
+ render.renderToElement({} as any);
+ expect(story.applyLoaders).toHaveBeenCalledOnce();
+ expect(render.phase).toBe('loading');
+ render.teardown();
+
+ // Assert - window is reloaded
+ await vi.waitFor(() => {
+ expect(window.location.reload).toHaveBeenCalledOnce();
+ expect(store.cleanupStory).toHaveBeenCalledOnce();
+ });
+ });
+
+ it('reloads the page when tearing down during rendering', async () => {
+ // Arrange - setup StoryRender and async gate blocking renderToScreen
+ const [renderGate] = createGate();
+ const story = {
+ id: 'id',
+ title: 'title',
+ name: 'name',
+ tags: [],
+ applyLoaders: vi.fn(),
+ applyBeforeEach: vi.fn(),
+ unboundStoryFn: vi.fn(),
+ playFunction: vi.fn(),
+ prepareContext: vi.fn(),
+ };
+ const store = {
+ getStoryContext: () => ({}),
+ cleanupStory: vi.fn(),
+ addCleanupCallbacks: vi.fn(),
+ };
+ const renderToScreen = vi.fn(() => renderGate);
+
+ const render = new StoryRender(
+ new Channel({}),
+ store as any,
+ renderToScreen as any,
+ {} as any,
+ entry.id,
+ 'story',
+ { autoplay: true },
+ story as any
+ );
+
+ // Act - render (blocked by renderToScreen), teardown
+ render.renderToElement({} as any);
+ await tick(); // go from 'loading' to 'rendering' phase
+ expect(renderToScreen).toHaveBeenCalledOnce();
+ expect(render.phase).toBe('rendering');
+ render.teardown();
+
+ // Assert - window is reloaded
+ await vi.waitFor(() => {
+ expect(window.location.reload).toHaveBeenCalledOnce();
+ expect(store.cleanupStory).toHaveBeenCalledOnce();
+ });
+ });
+
+ it('reloads the page when tearing down during playing', async () => {
+ // Arrange - setup StoryRender and async gate blocking playing
+ const [playGate] = createGate();
+ const story = {
+ id: 'id',
+ title: 'title',
+ name: 'name',
+ tags: [],
+ applyLoaders: vi.fn(),
+ applyBeforeEach: vi.fn(),
+ unboundStoryFn: vi.fn(),
+ playFunction: vi.fn(() => playGate),
+ prepareContext: vi.fn(),
+ };
+ const store = {
+ getStoryContext: () => ({}),
+ cleanupStory: vi.fn(),
+ addCleanupCallbacks: vi.fn(),
+ };
+
+ const render = new StoryRender(
+ new Channel({}),
+ store as any,
+ vi.fn() as any,
+ {} as any,
+ entry.id,
+ 'story',
+ { autoplay: true },
+ story as any
+ );
+
+ // Act - render (blocked by playFn), teardown
+ render.renderToElement({} as any);
+ await tick(); // go from 'loading' to 'beforeEach' phase
+ await tick(); // go from 'beforeEach' to 'playing' phase
+ expect(story.playFunction).toHaveBeenCalledOnce();
+ expect(render.phase).toBe('playing');
+ render.teardown();
+
+ // Assert - window is reloaded
+ await vi.waitFor(() => {
+ expect(window.location.reload).toHaveBeenCalledOnce();
+ expect(store.cleanupStory).toHaveBeenCalledOnce();
+ });
+ });
+
+ it('reloads the page when remounting during loading', async () => {
+ // Arrange - setup StoryRender and async gate blocking applyLoaders
+ const [loaderGate] = createGate();
+ const story = {
+ id: 'id',
+ title: 'title',
+ name: 'name',
+ tags: [],
+ applyLoaders: vi.fn(() => loaderGate),
+ applyBeforeEach: vi.fn(),
+ unboundStoryFn: vi.fn(),
+ playFunction: vi.fn(),
+ prepareContext: vi.fn(),
+ };
+ const store = {
+ getStoryContext: () => ({}),
+ cleanupStory: vi.fn(),
+ addCleanupCallbacks: vi.fn(),
+ };
+ const render = new StoryRender(
+ new Channel({}),
+ store as any,
+ vi.fn() as any,
+ {} as any,
+ entry.id,
+ 'story',
+ { autoplay: true },
+ story as any
+ );
+
+ // Act - render, blocked by loaders
+ render.renderToElement({} as any);
+ expect(story.applyLoaders).toHaveBeenCalledOnce();
+ expect(render.phase).toBe('loading');
+ // Act - remount
+ render.remount();
+
+ // Assert - window is reloaded
+ await vi.waitFor(() => {
+ expect(window.location.reload).toHaveBeenCalledOnce();
+ expect(store.cleanupStory).toHaveBeenCalledOnce();
+ });
+ });
+ });
});
diff --git a/code/lib/preview-api/src/modules/preview-web/render/StoryRender.ts b/code/lib/preview-api/src/modules/preview-web/render/StoryRender.ts
index ff3449b7a437..8ade66fc8942 100644
--- a/code/lib/preview-api/src/modules/preview-web/render/StoryRender.ts
+++ b/code/lib/preview-api/src/modules/preview-web/render/StoryRender.ts
@@ -27,6 +27,7 @@ const { AbortController } = globalThis;
export type RenderPhase =
| 'preparing'
| 'loading'
+ | 'beforeEach'
| 'rendering'
| 'playing'
| 'played'
@@ -56,6 +57,8 @@ export class StoryRender implements Render {};
@@ -101,7 +104,7 @@ export class StoryRender implements Render);
+ await this.store.cleanupStory(this.story as PreparedStory);
throw PREPARE_ABORTED;
}
}
@@ -120,7 +123,7 @@ export class StoryRender implements Render implements Render implements Render);
+ // TODO add this to CSF
+ canvasElement,
+ } as unknown as StoryContextForLoaders);
});
- if (abortSignal.aborted) {
- return;
- }
+ if (abortSignal.aborted) return;
const renderStoryContext: StoryContext = {
...loadedContext!,
@@ -187,6 +200,14 @@ export class StoryRender implements Render {
+ const cleanupCallbacks = await applyBeforeEach(renderStoryContext);
+ this.store.addCleanupCallbacks(story, cleanupCallbacks);
+ });
+
+ if (abortSignal.aborted) return;
+
const renderContext: RenderContext = {
componentId,
title,
@@ -265,13 +286,31 @@ export class StoryRender implements Render implements Render {
const { hooks } = store.getStoryContext(story) as { hooks: HooksContext };
hooks.clean = vi.fn();
- store.cleanupStory(story);
+ await store.cleanupStory(story);
expect(hooks.clean).toHaveBeenCalled();
});
});
@@ -621,6 +621,7 @@ describe('StoryStore', () => {
expect(store.raw()).toMatchInlineSnapshot(`
[
{
+ "applyBeforeEach": [Function],
"applyLoaders": [Function],
"argTypes": {
"a": {
@@ -666,6 +667,7 @@ describe('StoryStore', () => {
"undecoratedStoryFn": [Function],
},
{
+ "applyBeforeEach": [Function],
"applyLoaders": [Function],
"argTypes": {
"a": {
@@ -711,6 +713,7 @@ describe('StoryStore', () => {
"undecoratedStoryFn": [Function],
},
{
+ "applyBeforeEach": [Function],
"applyLoaders": [Function],
"argTypes": {
"a": {
diff --git a/code/lib/preview-api/src/modules/store/StoryStore.ts b/code/lib/preview-api/src/modules/store/StoryStore.ts
index 123ea5b984c8..ebfe582cdfe2 100644
--- a/code/lib/preview-api/src/modules/store/StoryStore.ts
+++ b/code/lib/preview-api/src/modules/store/StoryStore.ts
@@ -40,6 +40,7 @@ import {
normalizeProjectAnnotations,
prepareContext,
} from './csf';
+import type { CleanupCallback } from '@storybook/csf';
// TODO -- what are reasonable values for these?
const CSF_CACHE_SIZE = 1000;
@@ -56,6 +57,8 @@ export class StoryStore {
hooks: Record>;
+ cleanupCallbacks: Record;
+
cachedCSFFiles?: Record>;
processCSFFileWithCache: typeof processCSFFile;
@@ -79,6 +82,7 @@ export class StoryStore {
this.args = new ArgsStore();
this.globals = new GlobalsStore({ globals, globalTypes });
this.hooks = {};
+ this.cleanupCallbacks = {};
// We use a cache for these two functions for two reasons:
// 1. For performance
@@ -234,8 +238,17 @@ export class StoryStore {
});
}
- cleanupStory(story: PreparedStory): void {
+ addCleanupCallbacks(story: PreparedStory, callbacks: CleanupCallback[]) {
+ this.cleanupCallbacks[story.id] = callbacks;
+ }
+
+ async cleanupStory(story: PreparedStory): Promise {
this.hooks[story.id].clean();
+
+ const callbacks = this.cleanupCallbacks[story.id];
+ if (callbacks) for (const callback of [...callbacks].reverse()) await callback();
+
+ delete this.cleanupCallbacks[story.id];
}
extract(
diff --git a/code/lib/preview-api/src/modules/store/args.test.ts b/code/lib/preview-api/src/modules/store/args.test.ts
index 0d39874c766f..ed8ed42c483e 100644
--- a/code/lib/preview-api/src/modules/store/args.test.ts
+++ b/code/lib/preview-api/src/modules/store/args.test.ts
@@ -243,6 +243,7 @@ describe('validateOptions', () => {
});
it('ignores options and logs an error if options is not an array', () => {
+ // @ts-expect-error This should give TS error indeed (finally!)
expect(validateOptions({ a: 1 }, { a: { options: { 2: 'two' } } })).toStrictEqual({ a: 1 });
expect(once.error).toHaveBeenCalledWith(
expect.stringContaining("Invalid argType: 'a.options' should be an array")
diff --git a/code/lib/preview-api/src/modules/store/csf/composeConfigs.test.ts b/code/lib/preview-api/src/modules/store/csf/composeConfigs.test.ts
index 147038a5a8d2..bfad2ebe5f21 100644
--- a/code/lib/preview-api/src/modules/store/csf/composeConfigs.test.ts
+++ b/code/lib/preview-api/src/modules/store/csf/composeConfigs.test.ts
@@ -21,6 +21,7 @@ describe('composeConfigs', () => {
globals: {},
globalTypes: {},
loaders: [],
+ beforeEach: [],
runStep: expect.any(Function),
});
});
@@ -45,6 +46,7 @@ describe('composeConfigs', () => {
globals: {},
globalTypes: {},
loaders: [],
+ beforeEach: [],
runStep: expect.any(Function),
});
});
@@ -73,6 +75,7 @@ describe('composeConfigs', () => {
globals: {},
globalTypes: {},
loaders: [],
+ beforeEach: [],
runStep: expect.any(Function),
});
});
@@ -107,6 +110,7 @@ describe('composeConfigs', () => {
globals: { x: '2', y: '1', z: '2', obj: { a: '2', c: '2' } },
globalTypes: { x: '2', y: '1', z: '2', obj: { a: '2', c: '2' } },
loaders: [],
+ beforeEach: [],
runStep: expect.any(Function),
});
});
@@ -144,6 +148,7 @@ describe('composeConfigs', () => {
globals: { x: '2', y: '1', z: '2', obj: { a: '2', c: '2' } },
globalTypes: { x: '2', y: '1', z: '2', obj: { a: '2', c: '2' } },
loaders: [],
+ beforeEach: [],
runStep: expect.any(Function),
});
});
@@ -172,6 +177,7 @@ describe('composeConfigs', () => {
globals: {},
globalTypes: {},
loaders: ['1', '2', '3', '4'],
+ beforeEach: [],
runStep: expect.any(Function),
});
});
@@ -200,6 +206,7 @@ describe('composeConfigs', () => {
globals: {},
globalTypes: {},
loaders: ['1', '2', '3'],
+ beforeEach: [],
runStep: expect.any(Function),
});
});
@@ -224,6 +231,7 @@ describe('composeConfigs', () => {
globals: {},
globalTypes: {},
loaders: [],
+ beforeEach: [],
runStep: expect.any(Function),
});
});
@@ -249,6 +257,7 @@ describe('composeConfigs', () => {
globals: {},
globalTypes: {},
loaders: [],
+ beforeEach: [],
runStep: expect.any(Function),
});
});
@@ -277,6 +286,7 @@ describe('composeConfigs', () => {
globals: {},
globalTypes: {},
loaders: [],
+ beforeEach: [],
render: 'render-2',
renderToCanvas: 'renderToCanvas-2',
applyDecorators: 'applyDecorators-2',
diff --git a/code/lib/preview-api/src/modules/store/csf/composeConfigs.ts b/code/lib/preview-api/src/modules/store/csf/composeConfigs.ts
index e5785a6a3f01..12bf9fc9e050 100644
--- a/code/lib/preview-api/src/modules/store/csf/composeConfigs.ts
+++ b/code/lib/preview-api/src/modules/store/csf/composeConfigs.ts
@@ -58,6 +58,7 @@ export function composeConfigs(
globals: getObjectField(moduleExportList, 'globals'),
globalTypes: getObjectField(moduleExportList, 'globalTypes'),
loaders: getArrayField(moduleExportList, 'loaders'),
+ beforeEach: getArrayField(moduleExportList, 'beforeEach'),
render: getSingletonField(moduleExportList, 'render'),
renderToCanvas: getSingletonField(moduleExportList, 'renderToCanvas'),
renderToDOM: getSingletonField(moduleExportList, 'renderToDOM'), // deprecated
diff --git a/code/lib/preview-api/src/modules/store/csf/normalizeProjectAnnotations.ts b/code/lib/preview-api/src/modules/store/csf/normalizeProjectAnnotations.ts
index e2e6a88db31e..60cca0023352 100644
--- a/code/lib/preview-api/src/modules/store/csf/normalizeProjectAnnotations.ts
+++ b/code/lib/preview-api/src/modules/store/csf/normalizeProjectAnnotations.ts
@@ -16,6 +16,7 @@ export function normalizeProjectAnnotations({
argTypesEnhancers,
decorators,
loaders,
+ beforeEach,
...annotations
}: ProjectAnnotations): NormalizedProjectAnnotations {
return {
@@ -23,6 +24,7 @@ export function normalizeProjectAnnotations({
...(globalTypes && { globalTypes: normalizeInputTypes(globalTypes) }),
decorators: normalizeArrays(decorators),
loaders: normalizeArrays(loaders),
+ beforeEach: normalizeArrays(beforeEach),
argTypesEnhancers: [
...(argTypesEnhancers || []),
inferArgTypes,
diff --git a/code/lib/preview-api/src/modules/store/csf/normalizeStory.test.ts b/code/lib/preview-api/src/modules/store/csf/normalizeStory.test.ts
index a4bf5a04fdeb..f9e4b9cdca92 100644
--- a/code/lib/preview-api/src/modules/store/csf/normalizeStory.test.ts
+++ b/code/lib/preview-api/src/modules/store/csf/normalizeStory.test.ts
@@ -51,6 +51,7 @@ describe('normalizeStory', () => {
{
"argTypes": {},
"args": {},
+ "beforeEach": [],
"decorators": [],
"id": "title--story-export",
"loaders": [],
@@ -120,6 +121,7 @@ describe('normalizeStory', () => {
{
"argTypes": {},
"args": {},
+ "beforeEach": [],
"decorators": [],
"id": "title--story-export",
"loaders": [],
@@ -156,6 +158,7 @@ describe('normalizeStory', () => {
"args": {
"storyArg": "val",
},
+ "beforeEach": [],
"decorators": [
[Function],
],
@@ -211,6 +214,7 @@ describe('normalizeStory', () => {
"storyArg": "val",
"storyArg2": "legacy",
},
+ "beforeEach": [],
"decorators": [
[Function],
[Function],
diff --git a/code/lib/preview-api/src/modules/store/csf/normalizeStory.ts b/code/lib/preview-api/src/modules/store/csf/normalizeStory.ts
index 9a96bc4a332e..fc228251d82e 100644
--- a/code/lib/preview-api/src/modules/store/csf/normalizeStory.ts
+++ b/code/lib/preview-api/src/modules/store/csf/normalizeStory.ts
@@ -54,6 +54,10 @@ export function normalizeStory(
const args = { ...story?.args, ...storyObject.args };
const argTypes = { ...(story?.argTypes as ArgTypes), ...(storyObject.argTypes as ArgTypes) };
const loaders = [...normalizeArrays(storyObject.loaders), ...normalizeArrays(story?.loaders)];
+ const beforeEach = [
+ ...normalizeArrays(storyObject.beforeEach),
+ ...normalizeArrays(story?.beforeEach),
+ ];
const { render, play, tags = [] } = storyObject;
// eslint-disable-next-line no-underscore-dangle
@@ -68,6 +72,7 @@ export function normalizeStory(
args,
argTypes: normalizeInputTypes(argTypes),
loaders,
+ beforeEach,
...(render && { render }),
...(userStoryFn && { userStoryFn }),
...(play && { play }),
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 a3aa544c4827..9322d3c9d2a3 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
@@ -167,6 +167,137 @@ describe('composeStory', () => {
expect(spyFn).toHaveBeenCalled();
});
+ it('should work with spies set up in beforeEach', async () => {
+ const spyFn = vi.fn();
+
+ const Story: Story = {
+ args: {
+ spyFn,
+ },
+ beforeEach: 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 call beforeEach from Project, Meta and Story level', async () => {
+ const beforeEachSpy = vi.fn();
+ const cleanupSpy = vi.fn();
+
+ const metaObj: Meta = {
+ beforeEach: async () => {
+ beforeEachSpy('define from meta');
+
+ return () => {
+ cleanupSpy('cleanup from meta');
+ };
+ },
+ };
+
+ const Story: Story = {
+ render: () => 'foo',
+ beforeEach: async () => {
+ beforeEachSpy('define from story');
+
+ return () => {
+ cleanupSpy('cleanup from story');
+ };
+ },
+ };
+
+ const composedStory = composeStory(Story, metaObj, {
+ beforeEach: async () => {
+ beforeEachSpy('define from project');
+
+ return () => {
+ cleanupSpy('cleanup from project');
+ };
+ },
+ });
+ await composedStory.load();
+ composedStory();
+ expect(beforeEachSpy).toHaveBeenNthCalledWith(1, 'define from project');
+ expect(beforeEachSpy).toHaveBeenNthCalledWith(2, 'define from meta');
+ expect(beforeEachSpy).toHaveBeenNthCalledWith(3, 'define from story');
+
+ // simulate the next story's load to trigger cleanup
+ await composedStory.load();
+ expect(cleanupSpy).toHaveBeenNthCalledWith(1, 'cleanup from story');
+ expect(cleanupSpy).toHaveBeenNthCalledWith(2, 'cleanup from meta');
+ expect(cleanupSpy).toHaveBeenNthCalledWith(3, 'cleanup from project');
+ });
+
+ it('should call beforeEach after loaders', async () => {
+ const spyFn = vi.fn();
+
+ const Story: Story = {
+ render: () => 'foo',
+ loaders: async () => {
+ spyFn('from loaders');
+ },
+ beforeEach: async () => {
+ spyFn('from beforeEach');
+ },
+ };
+
+ const composedStory = composeStory(Story, {});
+ await composedStory.load();
+ expect(spyFn).toHaveBeenNthCalledWith(1, 'from loaders');
+ expect(spyFn).toHaveBeenNthCalledWith(2, 'from beforeEach');
+ });
+
+ it('should warn when previous cleanups are still around when rendering a story', async () => {
+ const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const cleanupSpy = vi.fn();
+ const beforeEachSpy = vi.fn(() => {
+ return () => {
+ cleanupSpy();
+ };
+ });
+
+ const PreviousStory: Story = {
+ render: () => 'first',
+ beforeEach: beforeEachSpy,
+ };
+ const CurrentStory: Story = {
+ render: () => 'second',
+ args: {
+ firstArg: false,
+ secondArg: true,
+ },
+ };
+ const firstComposedStory = composeStory(PreviousStory, {});
+ await firstComposedStory.load();
+ firstComposedStory();
+
+ expect(beforeEachSpy).toHaveBeenCalled();
+ expect(cleanupSpy).not.toHaveBeenCalled();
+ expect(consoleWarnSpy).not.toHaveBeenCalled();
+
+ const secondComposedStory = composeStory(CurrentStory, {});
+ secondComposedStory();
+
+ expect(cleanupSpy).not.toHaveBeenCalled();
+ expect(consoleWarnSpy).toHaveBeenCalledOnce();
+ expect(consoleWarnSpy.mock.calls[0][0]).toMatchInlineSnapshot(
+ `
+ "Some stories were not cleaned up before rendering 'Unnamed Story (firstArg, secondArg)'.
+
+ You should load the story with \`await Story.load()\` before rendering it.
+ See https://storybook.js.org/docs/api/portable-stories-vitest#3-load for more information."
+ `
+ );
+ });
+
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 57e8fcda9a2b..a4385605685e 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,6 +1,6 @@
/* eslint-disable no-underscore-dangle */
/* eslint-disable @typescript-eslint/naming-convention */
-import { isExportStory } from '@storybook/csf';
+import { type CleanupCallback, isExportStory } from '@storybook/csf';
import dedent from 'ts-dedent';
import type {
Renderer,
@@ -29,6 +29,9 @@ import { normalizeProjectAnnotations } from './normalizeProjectAnnotations';
let globalProjectAnnotations: ProjectAnnotations = {};
+const DEFAULT_STORY_TITLE = 'ComposedStory';
+const DEFAULT_STORY_NAME = 'Unnamed Story';
+
function extractAnnotation(
annotation: NamedOrDefaultProjectAnnotations
) {
@@ -47,6 +50,8 @@ export function setProjectAnnotations(
globalProjectAnnotations = composeConfigs(annotations.map(extractAnnotation));
}
+const cleanups: { storyName: string; callback: CleanupCallback }[] = [];
+
export function composeStory(
storyAnnotations: LegacyStoryAnnotationsOrFn,
componentAnnotations: ComponentAnnotations,
@@ -61,7 +66,7 @@ export function composeStory(componentAnnotations);
@@ -70,7 +75,7 @@ export function composeStory(
storyName,
@@ -113,6 +118,8 @@ export function composeStory> = Object.assign(
function storyFn(extraArgs?: Partial) {
context.args = {
@@ -120,14 +127,47 @@ export function composeStory 0 && !previousCleanupsDone) {
+ let humanReadableIdentifier = storyName;
+ if (story.title !== DEFAULT_STORY_TITLE) {
+ // prefix with title unless it's the generic ComposedStory title
+ humanReadableIdentifier = `${story.title} - ${humanReadableIdentifier}`;
+ }
+ if (storyName === DEFAULT_STORY_NAME && Object.keys(context.args).length > 0) {
+ // suffix with args if it's an unnamed story and there are args
+ humanReadableIdentifier = `${humanReadableIdentifier} (${Object.keys(context.args).join(
+ ', '
+ )})`;
+ }
+ console.warn(
+ dedent`Some stories were not cleaned up before rendering '${humanReadableIdentifier}'.
+
+ You should load the story with \`await Story.load()\` before rendering it.
+ See https://storybook.js.org/docs/api/portable-stories-${
+ process.env.JEST_WORKER_ID !== undefined ? 'jest' : 'vitest'
+ }#3-load for more information.`
+ );
+ }
return story.unboundStoryFn(prepareContext(context));
},
{
id: story.id,
storyName,
load: async () => {
+ // First run any registered cleanup function
+ for (const { callback } of [...cleanups].reverse()) await callback();
+ cleanups.length = 0;
+
+ previousCleanupsDone = true;
+
const loadedContext = await story.applyLoaders(context);
context.loaded = loadedContext.loaded;
+
+ cleanups.push(
+ ...(await story.applyBeforeEach(context))
+ .filter(Boolean)
+ .map((callback) => ({ storyName, callback }))
+ );
},
args: story.initialArgs as Partial,
parameters: story.parameters as Parameters,
diff --git a/code/lib/preview-api/src/modules/store/csf/prepareStory.test.ts b/code/lib/preview-api/src/modules/store/csf/prepareStory.test.ts
index b05485e5a506..5d5389beeb36 100644
--- a/code/lib/preview-api/src/modules/store/csf/prepareStory.test.ts
+++ b/code/lib/preview-api/src/modules/store/csf/prepareStory.test.ts
@@ -718,6 +718,7 @@ describe('prepareMeta', () => {
name: storyName,
story,
applyLoaders,
+ applyBeforeEach,
originalStoryFn,
unboundStoryFn,
undecoratedStoryFn,
diff --git a/code/lib/preview-api/src/modules/store/csf/prepareStory.ts b/code/lib/preview-api/src/modules/store/csf/prepareStory.ts
index 0e5ea37500cd..84439187c4de 100644
--- a/code/lib/preview-api/src/modules/store/csf/prepareStory.ts
+++ b/code/lib/preview-api/src/modules/store/csf/prepareStory.ts
@@ -20,7 +20,7 @@ import type {
StoryContextForLoaders,
StrictArgTypes,
} from '@storybook/types';
-import { includeConditionalArg } from '@storybook/csf';
+import { type CleanupCallback, includeConditionalArg } from '@storybook/csf';
import { applyHooks } from '../../addons';
import { combineParameters } from '../parameters';
@@ -65,9 +65,23 @@ export function prepareStory(
const loaded: Record = Object.assign({}, ...loadResults);
updatedContext = { ...updatedContext, loaded: { ...updatedContext.loaded, ...loaded } };
}
+
return updatedContext;
};
+ const applyBeforeEach = async (context: StoryContext): Promise => {
+ const cleanupCallbacks = new Array<() => unknown>();
+ for (const beforeEach of [
+ ...normalizeArrays(projectAnnotations.beforeEach),
+ ...normalizeArrays(componentAnnotations.beforeEach),
+ ...normalizeArrays(storyAnnotations.beforeEach),
+ ]) {
+ const cleanup = await beforeEach(context);
+ if (cleanup) cleanupCallbacks.push(cleanup);
+ }
+ return cleanupCallbacks;
+ };
+
const undecoratedStoryFn = (context: StoryContext) =>
(render as ArgsStoryFn)(context.args, context);
@@ -117,6 +131,7 @@ export function prepareStory(
undecoratedStoryFn,
unboundStoryFn,
applyLoaders,
+ applyBeforeEach,
playFunction,
};
}
@@ -250,10 +265,10 @@ export function prepareContext<
return acc;
}
- const mappingFn = (originalValue: any) =>
- originalValue in targetedContext.argTypes[key].mapping
- ? targetedContext.argTypes[key].mapping[originalValue]
- : originalValue;
+ const mappingFn = (originalValue: any) => {
+ const mapping = targetedContext.argTypes[key].mapping;
+ return mapping && originalValue in mapping ? mapping[originalValue] : originalValue;
+ };
acc[key] = Array.isArray(val) ? val.map(mappingFn) : mappingFn(val);
diff --git a/code/lib/preview-api/src/modules/store/csf/stepRunners.ts b/code/lib/preview-api/src/modules/store/csf/stepRunners.ts
index 2a5b5be88e17..efc2d583bf31 100644
--- a/code/lib/preview-api/src/modules/store/csf/stepRunners.ts
+++ b/code/lib/preview-api/src/modules/store/csf/stepRunners.ts
@@ -3,7 +3,7 @@ import type { Renderer, StepRunner } from '@storybook/types';
/**
* Compose step runners to create a single step runner that applies each step runner in order.
*
- * A step runner is a a function that takes a defined step: `step('label', () => { ... })`
+ * A step runner is a function that takes a defined step: `step('label', () => { ... })`
* and runs it. The prototypical example is from `@storybook/addon-interactions` where the
* step runner will decorate all instrumented code inside the step with information about the
* label.
diff --git a/code/lib/preview-api/src/modules/store/inferControls.test.ts b/code/lib/preview-api/src/modules/store/inferControls.test.ts
index 8d754372439d..9293bf771e69 100644
--- a/code/lib/preview-api/src/modules/store/inferControls.test.ts
+++ b/code/lib/preview-api/src/modules/store/inferControls.test.ts
@@ -58,7 +58,8 @@ describe('inferControls', () => {
})
);
- expect(inferredControls.background.control.type).toEqual('color');
+ const control = inferredControls.background.control;
+ expect(typeof control === 'object' && control.type).toEqual('color');
});
it('should return inferred type when using color matcher but arg passed is not a string', () => {
@@ -97,7 +98,8 @@ describe('inferControls', () => {
);
expect(warnSpy).toHaveBeenCalled();
- expect(inferredControls.background.control.type).toEqual(type.name);
+ const control = inferredControls.background.control;
+ expect(typeof control === 'object' && control.type).toEqual(type.name);
});
});
});
diff --git a/code/lib/preview-api/template/stories/rendering.stories.ts b/code/lib/preview-api/template/stories/rendering.stories.ts
index 477e07137987..aa521c6fed91 100644
--- a/code/lib/preview-api/template/stories/rendering.stories.ts
+++ b/code/lib/preview-api/template/stories/rendering.stories.ts
@@ -66,3 +66,34 @@ export const ChangeArgs = {
await expect(button).toHaveFocus();
},
};
+
+let loadedLabel = 'Initial';
+
+/**
+ * This story demonstrates what happens when rendering (loaders) have side effects, and can possibly interleave with each other
+ * Triggering multiple force remounts quickly should only result in a single remount in the end
+ * and the label should be 'Loaded. Click Me' at the end. If loaders are interleaving it would result in a label of 'Error: Interleaved loaders. Click Me'
+ * Similarly, changing args rapidly should only cause one rerender at a time, producing the same result.
+ */
+export const SlowLoader = {
+ parameters: {
+ chromatic: { disable: true },
+ },
+ loaders: [
+ async () => {
+ loadedLabel = 'Loading...';
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ loadedLabel = loadedLabel === 'Loading...' ? 'Loaded.' : 'Error: Interleaved loaders.';
+ return { label: loadedLabel };
+ },
+ ],
+ decorators: [
+ (storyFn: any, context: any) =>
+ storyFn({
+ args: {
+ ...context.args,
+ label: `${context.loaded.label} ${context.args.label}`,
+ },
+ }),
+ ],
+};
diff --git a/code/lib/react-dom-shim/src/preset.ts b/code/lib/react-dom-shim/src/preset.ts
index e863a53262b6..fbcb353c3827 100644
--- a/code/lib/react-dom-shim/src/preset.ts
+++ b/code/lib/react-dom-shim/src/preset.ts
@@ -6,7 +6,7 @@ import { readFile } from 'fs/promises';
* Get react-dom version from the resolvedReact preset, which points to either
* a root react-dom dependency or the react-dom dependency shipped with addon-docs
*/
-const getIsReactVersion18 = async (options: Options) => {
+const getIsReactVersion18or19 = async (options: Options) => {
const { legacyRootApi } =
(await options.presets.apply<{ legacyRootApi?: boolean } | null>('frameworkOptions')) || {};
@@ -24,11 +24,11 @@ const getIsReactVersion18 = async (options: Options) => {
}
const { version } = JSON.parse(await readFile(join(reactDom, 'package.json'), 'utf-8'));
- return version.startsWith('18') || version.startsWith('0.0.0');
+ return version.startsWith('18') || version.startsWith('19') || version.startsWith('0.0.0');
};
export const webpackFinal = async (config: any, options: Options) => {
- const isReactVersion18 = await getIsReactVersion18(options);
+ const isReactVersion18 = await getIsReactVersion18or19(options);
if (isReactVersion18) {
return config;
}
@@ -46,7 +46,7 @@ export const webpackFinal = async (config: any, options: Options) => {
};
export const viteFinal = async (config: any, options: Options) => {
- const isReactVersion18 = await getIsReactVersion18(options);
+ const isReactVersion18 = await getIsReactVersion18or19(options);
if (isReactVersion18) {
return config;
}
diff --git a/code/lib/source-loader/package.json b/code/lib/source-loader/package.json
index 7bb4e1d5688b..4c8916ae6631 100644
--- a/code/lib/source-loader/package.json
+++ b/code/lib/source-loader/package.json
@@ -45,7 +45,7 @@
"prep": "node --loader ../../../scripts/node_modules/esbuild-register/loader.js -r ../../../scripts/node_modules/esbuild-register/register.js ../../../scripts/prepare/bundle.ts"
},
"dependencies": {
- "@storybook/csf": "^0.1.2",
+ "@storybook/csf": "^0.1.6",
"@storybook/types": "workspace:*",
"estraverse": "^5.2.0",
"lodash": "^4.17.21",
diff --git a/code/lib/telemetry/src/types.ts b/code/lib/telemetry/src/types.ts
index 846e7adb9556..c6372084c2c8 100644
--- a/code/lib/telemetry/src/types.ts
+++ b/code/lib/telemetry/src/types.ts
@@ -16,7 +16,10 @@ export type EventType =
| 'error-metadata'
| 'version-update'
| 'core-config'
- | 'remove';
+ | 'remove'
+ | 'save-story'
+ | 'create-new-story-file'
+ | 'create-new-story-file-search';
export interface Dependency {
version: string | undefined;
diff --git a/code/lib/test/package.json b/code/lib/test/package.json
index e9b34ccb2098..502f18c5140e 100644
--- a/code/lib/test/package.json
+++ b/code/lib/test/package.json
@@ -52,10 +52,10 @@
"@testing-library/user-event": "^14.5.2",
"@vitest/expect": "1.3.1",
"@vitest/spy": "^1.3.1",
- "chai": "^4.4.1",
"util": "^0.12.4"
},
"devDependencies": {
+ "chai": "^4.4.1",
"tinyspy": "^2.2.0",
"ts-dedent": "^2.2.0",
"type-fest": "~2.19",
diff --git a/code/lib/test/template/stories/before-each.stories.ts b/code/lib/test/template/stories/before-each.stories.ts
new file mode 100644
index 000000000000..9af203a7baaf
--- /dev/null
+++ b/code/lib/test/template/stories/before-each.stories.ts
@@ -0,0 +1,31 @@
+import { expect, mocked, getByRole, spyOn, userEvent } from '@storybook/test';
+
+const meta = {
+ component: globalThis.Components.Button,
+ beforeEach() {
+ spyOn(console, 'log').mockName('console.log');
+ console.log('first');
+ },
+};
+
+export default meta;
+
+export const BeforeEachOrder = {
+ parameters: {
+ chromatic: { disable: true },
+ },
+ beforeEach() {
+ console.log('second');
+ },
+ args: {
+ label: 'Button',
+ onClick: () => {
+ console.log('third');
+ },
+ },
+ async play({ canvasElement }) {
+ await userEvent.click(getByRole(canvasElement, 'button'));
+
+ await expect(mocked(console.log).mock.calls).toEqual([['first'], ['second'], ['third']]);
+ },
+};
diff --git a/code/lib/test/template/stories/module-mocking.stories.ts b/code/lib/test/template/stories/module-mocking.stories.ts
new file mode 100644
index 000000000000..8332183005a2
--- /dev/null
+++ b/code/lib/test/template/stories/module-mocking.stories.ts
@@ -0,0 +1,28 @@
+import { global as globalThis } from '@storybook/global';
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore This alias is set in the sandbox. Using ts-ignore instead of ts-expect-error to avoid build errors in the sandbox.
+// eslint-disable-next-line import/no-unresolved
+import { foo } from '#utils';
+import { expect, fn, isMockFunction, mocked } from '@storybook/test';
+
+export default {
+ component: globalThis.Components.Button,
+ args: {
+ onClick: fn(),
+ label: 'Mock story',
+ },
+ parameters: {
+ chromatic: {
+ disable: true,
+ },
+ },
+ beforeEach: () => {
+ mocked(foo).mockReturnValue('mocked');
+ },
+ async play() {
+ await expect(isMockFunction(foo)).toBe(true);
+ await expect(foo()).toBe('mocked');
+ },
+};
+
+export const Basic = {};
diff --git a/code/lib/test/template/stories/utils.mock.ts b/code/lib/test/template/stories/utils.mock.ts
new file mode 100644
index 000000000000..15c648e88c00
--- /dev/null
+++ b/code/lib/test/template/stories/utils.mock.ts
@@ -0,0 +1,4 @@
+import { fn } from '@storybook/test';
+import * as utils from './utils';
+
+export const foo = fn(utils.foo);
diff --git a/code/lib/test/template/stories/utils.ts b/code/lib/test/template/stories/utils.ts
new file mode 100644
index 000000000000..5a80b1903c1f
--- /dev/null
+++ b/code/lib/test/template/stories/utils.ts
@@ -0,0 +1 @@
+export const foo = () => 'not mocked';
diff --git a/code/lib/types/package.json b/code/lib/types/package.json
index 94f137bfc62b..57eae04e53f5 100644
--- a/code/lib/types/package.json
+++ b/code/lib/types/package.json
@@ -49,7 +49,7 @@
"file-system-cache": "2.3.0"
},
"devDependencies": {
- "@storybook/csf": "^0.1.2",
+ "@storybook/csf": "^0.1.6",
"@types/fs-extra": "^11.0.1",
"@types/node": "^18.0.0",
"typescript": "^5.3.2"
diff --git a/code/lib/types/src/modules/story.ts b/code/lib/types/src/modules/story.ts
index ee34ad43ce79..041aaed2e709 100644
--- a/code/lib/types/src/modules/story.ts
+++ b/code/lib/types/src/modules/story.ts
@@ -3,6 +3,7 @@ import type {
ProjectAnnotations as CsfProjectAnnotations,
DecoratorFunction,
LoaderFunction,
+ CleanupCallback,
} from '@storybook/csf';
import type {
@@ -103,6 +104,7 @@ export type PreparedStory =
applyLoaders: (
context: StoryContextForLoaders
) => Promise & { loaded: StoryContext['loaded'] }>;
+ applyBeforeEach: (context: StoryContext) => Promise;
playFunction?: (context: StoryContext) => Promise | void;
};
diff --git a/code/package.json b/code/package.json
index 34840b62567a..12fbd04d33ab 100644
--- a/code/package.json
+++ b/code/package.json
@@ -90,7 +90,7 @@
"type-fest": "~2.19"
},
"dependencies": {
- "@chromatic-com/storybook": "^1.2.18",
+ "@chromatic-com/storybook": "^1.3.2",
"@nx/eslint": "18.0.6",
"@nx/vite": "18.0.6",
"@nx/workspace": "18.0.6",
@@ -127,7 +127,7 @@
"@storybook/core-events": "workspace:*",
"@storybook/core-server": "workspace:*",
"@storybook/core-webpack": "workspace:*",
- "@storybook/csf": "^0.1.2",
+ "@storybook/csf": "^0.1.6",
"@storybook/csf-plugin": "workspace:*",
"@storybook/csf-tools": "workspace:*",
"@storybook/docs-tools": "workspace:*",
@@ -299,5 +299,6 @@
"Dependency Upgrades"
]
]
- }
+ },
+ "deferredNextVersion": "8.1.0-alpha.8"
}
diff --git a/code/renderers/react/src/docs/extractArgTypes.ts b/code/renderers/react/src/docs/extractArgTypes.ts
index 2da2e2724e7d..7c4c55911255 100644
--- a/code/renderers/react/src/docs/extractArgTypes.ts
+++ b/code/renderers/react/src/docs/extractArgTypes.ts
@@ -22,9 +22,9 @@ export const extractArgTypes: ArgTypesExtractor = (component) => {
description,
type: { required, ...sbType },
table: {
- type,
+ type: type ?? undefined,
jsDocTags,
- defaultValue: defaultSummary,
+ defaultValue: defaultSummary ?? undefined,
},
};
return acc;
diff --git a/code/renderers/react/template/stories/docgen-components/10017-ts-union/argTypes.snapshot b/code/renderers/react/template/stories/docgen-components/10017-ts-union/argTypes.snapshot
index a8f478974310..0b533788e837 100644
--- a/code/renderers/react/template/stories/docgen-components/10017-ts-union/argTypes.snapshot
+++ b/code/renderers/react/template/stories/docgen-components/10017-ts-union/argTypes.snapshot
@@ -6,7 +6,7 @@
"description": "specify icon="search" or icon={IconComponent}",
"name": "icon",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"detail": undefined,
diff --git a/code/renderers/react/template/stories/docgen-components/10278-ts-multiple-components/argTypes.snapshot b/code/renderers/react/template/stories/docgen-components/10278-ts-multiple-components/argTypes.snapshot
index 78a50d36f3ca..82355abfb51b 100644
--- a/code/renderers/react/template/stories/docgen-components/10278-ts-multiple-components/argTypes.snapshot
+++ b/code/renderers/react/template/stories/docgen-components/10278-ts-multiple-components/argTypes.snapshot
@@ -6,7 +6,7 @@
"description": "",
"name": "aProperty",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"detail": undefined,
diff --git a/code/renderers/react/template/stories/docgen-components/8140-js-prop-types-oneof/argTypes.snapshot b/code/renderers/react/template/stories/docgen-components/8140-js-prop-types-oneof/argTypes.snapshot
index 7b21f298a2b6..af4ea9528f45 100644
--- a/code/renderers/react/template/stories/docgen-components/8140-js-prop-types-oneof/argTypes.snapshot
+++ b/code/renderers/react/template/stories/docgen-components/8140-js-prop-types-oneof/argTypes.snapshot
@@ -6,7 +6,7 @@
"description": "No background or border if static alert",
"name": "blank",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"detail": undefined,
@@ -25,7 +25,7 @@
"description": "Allows icon override, accepts material icon name",
"name": "icon",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"detail": undefined,
@@ -44,7 +44,7 @@
"description": "",
"name": "message",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"detail": undefined,
diff --git a/code/renderers/react/template/stories/docgen-components/8143-ts-imported-types/argTypes.snapshot b/code/renderers/react/template/stories/docgen-components/8143-ts-imported-types/argTypes.snapshot
index f95af2d02b91..ac64197be1b9 100644
--- a/code/renderers/react/template/stories/docgen-components/8143-ts-imported-types/argTypes.snapshot
+++ b/code/renderers/react/template/stories/docgen-components/8143-ts-imported-types/argTypes.snapshot
@@ -6,7 +6,7 @@
"description": "",
"name": "bar",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"detail": undefined,
diff --git a/code/renderers/react/template/stories/docgen-components/8428-js-static-prop-types/argTypes.snapshot b/code/renderers/react/template/stories/docgen-components/8428-js-static-prop-types/argTypes.snapshot
index 4e6031c33335..70cdd2d53019 100644
--- a/code/renderers/react/template/stories/docgen-components/8428-js-static-prop-types/argTypes.snapshot
+++ b/code/renderers/react/template/stories/docgen-components/8428-js-static-prop-types/argTypes.snapshot
@@ -6,7 +6,7 @@
"description": "Please work...",
"name": "test",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"detail": undefined,
diff --git a/code/renderers/react/template/stories/docgen-components/8428-js-static-prop-types/docgen.snapshot b/code/renderers/react/template/stories/docgen-components/8428-js-static-prop-types/docgen.snapshot
index 51b837feb2b6..2fb12d5579e7 100644
--- a/code/renderers/react/template/stories/docgen-components/8428-js-static-prop-types/docgen.snapshot
+++ b/code/renderers/react/template/stories/docgen-components/8428-js-static-prop-types/docgen.snapshot
@@ -1,5 +1,5 @@
function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
-function _toPropertyKey(t) { var i = _toPrimitive(t, "string"); return "symbol" == typeof i ? i : String(i); }
+function _toPropertyKey(t) { var i = _toPrimitive(t, "string"); return "symbol" == typeof i ? i : i + ""; }
function _toPrimitive(t, r) { if ("object" != typeof t || !t) return t; var e = t[Symbol.toPrimitive]; if (void 0 !== e) { var i = e.call(t, r || "default"); if ("object" != typeof i) return i; throw new TypeError("@@toPrimitive must return a primitive value."); } return ("string" === r ? String : Number)(t); }
import React from 'react';
import PropTypes from 'prop-types';
diff --git a/code/renderers/react/template/stories/docgen-components/9023-js-hoc/argTypes.snapshot b/code/renderers/react/template/stories/docgen-components/9023-js-hoc/argTypes.snapshot
index a243641da870..32718313fe4e 100644
--- a/code/renderers/react/template/stories/docgen-components/9023-js-hoc/argTypes.snapshot
+++ b/code/renderers/react/template/stories/docgen-components/9023-js-hoc/argTypes.snapshot
@@ -6,7 +6,7 @@
"description": "",
"name": "classes",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"detail": undefined,
@@ -47,7 +47,7 @@
"description": "",
"name": "icon",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"detail": undefined,
diff --git a/code/renderers/react/template/stories/docgen-components/9399-js-proptypes-shape/argTypes.snapshot b/code/renderers/react/template/stories/docgen-components/9399-js-proptypes-shape/argTypes.snapshot
index 619552956691..08e3d4d50f67 100644
--- a/code/renderers/react/template/stories/docgen-components/9399-js-proptypes-shape/argTypes.snapshot
+++ b/code/renderers/react/template/stories/docgen-components/9399-js-proptypes-shape/argTypes.snapshot
@@ -6,7 +6,7 @@
"description": "",
"name": "areas",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"detail": "[object]",
diff --git a/code/renderers/react/template/stories/docgen-components/9493-ts-display-name/argTypes.snapshot b/code/renderers/react/template/stories/docgen-components/9493-ts-display-name/argTypes.snapshot
index 1937e2f0b929..5b86db7699ab 100644
--- a/code/renderers/react/template/stories/docgen-components/9493-ts-display-name/argTypes.snapshot
+++ b/code/renderers/react/template/stories/docgen-components/9493-ts-display-name/argTypes.snapshot
@@ -6,7 +6,7 @@
"description": "A message alerting about Empire activities.",
"name": "message",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"detail": undefined,
diff --git a/code/renderers/react/template/stories/docgen-components/9586-js-react-memo/argTypes.snapshot b/code/renderers/react/template/stories/docgen-components/9586-js-react-memo/argTypes.snapshot
index 08c5a1cadec0..927cda14fc96 100644
--- a/code/renderers/react/template/stories/docgen-components/9586-js-react-memo/argTypes.snapshot
+++ b/code/renderers/react/template/stories/docgen-components/9586-js-react-memo/argTypes.snapshot
@@ -6,7 +6,7 @@
"description": "",
"name": "label",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"detail": undefined,
@@ -22,7 +22,7 @@
"description": "",
"name": "onClick",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"detail": undefined,
diff --git a/code/renderers/react/template/stories/docgen-components/9591-ts-import-types/argTypes.snapshot b/code/renderers/react/template/stories/docgen-components/9591-ts-import-types/argTypes.snapshot
index d6450dd81aae..83d91924fb0c 100644
--- a/code/renderers/react/template/stories/docgen-components/9591-ts-import-types/argTypes.snapshot
+++ b/code/renderers/react/template/stories/docgen-components/9591-ts-import-types/argTypes.snapshot
@@ -6,7 +6,7 @@
"description": "",
"name": "other",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"detail": undefined,
diff --git a/code/renderers/react/template/stories/docgen-components/9668-js-proptypes-no-jsdoc/argTypes.snapshot b/code/renderers/react/template/stories/docgen-components/9668-js-proptypes-no-jsdoc/argTypes.snapshot
index 42a58cd67545..590f3741b90b 100644
--- a/code/renderers/react/template/stories/docgen-components/9668-js-proptypes-no-jsdoc/argTypes.snapshot
+++ b/code/renderers/react/template/stories/docgen-components/9668-js-proptypes-no-jsdoc/argTypes.snapshot
@@ -6,7 +6,7 @@
"description": "",
"name": "heads",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"detail": undefined,
@@ -23,7 +23,7 @@
"description": "",
"name": "onAddClick",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"detail": undefined,
diff --git a/code/renderers/react/template/stories/docgen-components/9721-ts-deprecated-jsdoc/argTypes.snapshot b/code/renderers/react/template/stories/docgen-components/9721-ts-deprecated-jsdoc/argTypes.snapshot
index fbce25797699..e58f2727aefc 100644
--- a/code/renderers/react/template/stories/docgen-components/9721-ts-deprecated-jsdoc/argTypes.snapshot
+++ b/code/renderers/react/template/stories/docgen-components/9721-ts-deprecated-jsdoc/argTypes.snapshot
@@ -6,7 +6,7 @@
"description": "The size (replaces width)",
"name": "size",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"detail": undefined,
diff --git a/code/renderers/react/template/stories/docgen-components/9764-ts-extend-props/argTypes.snapshot b/code/renderers/react/template/stories/docgen-components/9764-ts-extend-props/argTypes.snapshot
index 61caa309227d..5c8758c8de31 100644
--- a/code/renderers/react/template/stories/docgen-components/9764-ts-extend-props/argTypes.snapshot
+++ b/code/renderers/react/template/stories/docgen-components/9764-ts-extend-props/argTypes.snapshot
@@ -6,7 +6,7 @@
"description": "",
"name": "checked",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"detail": undefined,
@@ -25,7 +25,7 @@
"description": "",
"name": "defaultChecked",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"detail": undefined,
@@ -44,7 +44,7 @@
"description": "The input content value",
"name": "value",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"detail": undefined,
diff --git a/code/renderers/react/template/stories/docgen-components/9827-ts-default-values/argTypes.snapshot b/code/renderers/react/template/stories/docgen-components/9827-ts-default-values/argTypes.snapshot
index 78780fc49909..84a578fb287c 100644
--- a/code/renderers/react/template/stories/docgen-components/9827-ts-default-values/argTypes.snapshot
+++ b/code/renderers/react/template/stories/docgen-components/9827-ts-default-values/argTypes.snapshot
@@ -6,7 +6,7 @@
"description": "",
"name": "bar",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"detail": undefined,
@@ -31,7 +31,7 @@
"description": "",
"name": "foo",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"detail": undefined,
diff --git a/code/renderers/react/template/stories/docgen-components/9922-ts-component-props/argTypes.snapshot b/code/renderers/react/template/stories/docgen-components/9922-ts-component-props/argTypes.snapshot
index 222aace445d3..2d1174767e7b 100644
--- a/code/renderers/react/template/stories/docgen-components/9922-ts-component-props/argTypes.snapshot
+++ b/code/renderers/react/template/stories/docgen-components/9922-ts-component-props/argTypes.snapshot
@@ -6,7 +6,7 @@
"description": "",
"name": "spacing",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"detail": undefined,
diff --git a/code/renderers/react/template/stories/docgen-components/jsdoc/argTypes.snapshot b/code/renderers/react/template/stories/docgen-components/jsdoc/argTypes.snapshot
index c37916b55746..492640c6e1be 100644
--- a/code/renderers/react/template/stories/docgen-components/jsdoc/argTypes.snapshot
+++ b/code/renderers/react/template/stories/docgen-components/jsdoc/argTypes.snapshot
@@ -6,7 +6,7 @@
"description": "simple description.",
"name": "case1",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"detail": undefined,
@@ -22,7 +22,7 @@
"description": "param with name",
"name": "case10",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": {
"deprecated": null,
"ignore": false,
@@ -48,7 +48,7 @@
"description": "param with name & type",
"name": "case11",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": {
"deprecated": null,
"ignore": false,
@@ -74,7 +74,7 @@
"description": "param with name, type & description",
"name": "case12",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": {
"deprecated": null,
"ignore": false,
@@ -100,7 +100,7 @@
"description": "param with type",
"name": "case13",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": {
"deprecated": null,
"ignore": false,
@@ -126,7 +126,7 @@
"description": "param with type & description",
"name": "case14",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"detail": undefined,
@@ -142,7 +142,7 @@
"description": "param with name & description",
"name": "case15",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": {
"deprecated": null,
"ignore": false,
@@ -168,7 +168,7 @@
"description": "autofix event-",
"name": "case16",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": {
"deprecated": null,
"ignore": false,
@@ -194,7 +194,7 @@
"description": "autofix event.",
"name": "case17",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": {
"deprecated": null,
"ignore": false,
@@ -227,7 +227,7 @@
"description": "with an empty param.",
"name": "case18",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"detail": undefined,
@@ -243,7 +243,7 @@
"description": "with multiple empty params.",
"name": "case19",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"detail": undefined,
@@ -264,7 +264,7 @@ lines
description",
"name": "case2",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"detail": undefined,
@@ -280,7 +280,7 @@ description",
"description": "with arg alias.",
"name": "case20",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": {
"deprecated": null,
"ignore": false,
@@ -306,7 +306,7 @@ description",
"description": "with argument alias.",
"name": "case21",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": {
"deprecated": null,
"ignore": false,
@@ -332,7 +332,7 @@ description",
"description": "with multiple params.",
"name": "case22",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": {
"deprecated": null,
"ignore": false,
@@ -366,7 +366,7 @@ description",
"description": "with an empty returns",
"name": "case23",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"detail": undefined,
@@ -382,7 +382,7 @@ description",
"description": "with a returns with a type",
"name": "case24",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": {
"deprecated": null,
"ignore": false,
@@ -410,7 +410,7 @@ description",
"description": "with a returns with a type & description",
"name": "case25",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": {
"deprecated": null,
"ignore": false,
@@ -438,7 +438,7 @@ description",
"description": "single param and a returns",
"name": "case26",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": {
"deprecated": null,
"ignore": false,
@@ -471,7 +471,7 @@ description",
"description": "multiple params and a returns",
"name": "case27",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": {
"deprecated": null,
"ignore": false,
@@ -508,7 +508,7 @@ description",
"description": "multiple returns",
"name": "case28",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": {
"deprecated": null,
"ignore": false,
@@ -536,7 +536,7 @@ description",
"description": "param with unsupported JSDoc tags",
"name": "case29",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": {
"deprecated": null,
"ignore": false,
@@ -565,7 +565,7 @@ description",
"description": "*description* **with** `formatting`",
"name": "case3",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"detail": undefined,
@@ -581,7 +581,7 @@ description",
"description": "param record type",
"name": "case30",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": {
"deprecated": null,
"ignore": false,
@@ -607,7 +607,7 @@ description",
"description": "param array type",
"name": "case31",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": {
"deprecated": null,
"ignore": false,
@@ -633,7 +633,7 @@ description",
"description": "param union type",
"name": "case32",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": {
"deprecated": null,
"ignore": false,
@@ -659,7 +659,7 @@ description",
"description": "param any type",
"name": "case33",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": {
"deprecated": null,
"ignore": false,
@@ -685,7 +685,7 @@ description",
"description": "param repeatable type",
"name": "case34",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": {
"deprecated": null,
"ignore": false,
@@ -711,7 +711,7 @@ description",
"description": "optional param",
"name": "case35",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": {
"deprecated": null,
"ignore": false,
@@ -737,7 +737,7 @@ description",
"description": "optional param",
"name": "case36",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": {
"deprecated": null,
"ignore": false,
@@ -763,7 +763,7 @@ description",
"description": "dot in param name",
"name": "case37",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": {
"deprecated": null,
"ignore": false,
@@ -789,7 +789,7 @@ description",
"description": "returns record type",
"name": "case38",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": {
"deprecated": null,
"ignore": false,
@@ -834,7 +834,7 @@ description",
"description": "returns array type",
"name": "case39",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": {
"deprecated": null,
"ignore": false,
@@ -874,7 +874,7 @@ description",
"description": "simple description and dummy JSDoc tag.",
"name": "case4",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": {
"deprecated": null,
"ignore": false,
@@ -900,7 +900,7 @@ description",
"description": "returns union type",
"name": "case40",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": {
"deprecated": null,
"ignore": false,
@@ -937,7 +937,7 @@ description",
"description": "returns any type",
"name": "case41",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": {
"deprecated": null,
"ignore": false,
@@ -964,7 +964,7 @@ description",
"description": "returns primitive",
"name": "case42",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": {
"deprecated": null,
"ignore": false,
@@ -992,7 +992,7 @@ description",
"description": "returns void",
"name": "case43",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": {
"deprecated": null,
"ignore": false,
@@ -1023,7 +1023,7 @@ description",
"description": "",
"name": "case5",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": {
"deprecated": null,
"ignore": false,
@@ -1052,7 +1052,7 @@ description",
"description": "simple description with a @.",
"name": "case6",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"detail": undefined,
@@ -1068,7 +1068,7 @@ description",
"description": "",
"name": "case7",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"detail": undefined,
@@ -1084,7 +1084,7 @@ description",
"description": "func with a simple description.",
"name": "case8",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"detail": undefined,
@@ -1100,7 +1100,7 @@ description",
"description": "",
"name": "case9",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": {
"deprecated": null,
"ignore": false,
diff --git a/code/renderers/react/template/stories/docgen-components/ts-types/argTypes.snapshot b/code/renderers/react/template/stories/docgen-components/ts-types/argTypes.snapshot
index 73094699e177..21ff1cd3a802 100644
--- a/code/renderers/react/template/stories/docgen-components/ts-types/argTypes.snapshot
+++ b/code/renderers/react/template/stories/docgen-components/ts-types/argTypes.snapshot
@@ -365,7 +365,7 @@
"description": undefined,
"name": "nullableComplexTypeUndefinedDefaultValue",
"table": {
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"detail": undefined,
diff --git a/code/renderers/server/package.json b/code/renderers/server/package.json
index 5b8dac785d41..b045700b4373 100644
--- a/code/renderers/server/package.json
+++ b/code/renderers/server/package.json
@@ -46,7 +46,7 @@
"prep": "node --loader ../../../scripts/node_modules/esbuild-register/loader.js -r ../../../scripts/node_modules/esbuild-register/register.js ../../../scripts/prepare/bundle.ts"
},
"dependencies": {
- "@storybook/csf": "^0.1.2",
+ "@storybook/csf": "^0.1.6",
"@storybook/csf-tools": "workspace:*",
"@storybook/global": "^5.0.0",
"@storybook/preview-api": "workspace:*",
diff --git a/code/renderers/server/src/render.ts b/code/renderers/server/src/render.ts
index 0a768647d740..0c910f7ba706 100644
--- a/code/renderers/server/src/render.ts
+++ b/code/renderers/server/src/render.ts
@@ -22,7 +22,8 @@ const buildStoryArgs = (args: Args, argTypes: ArgTypes) => {
Object.keys(argTypes).forEach((key: string) => {
const argType = argTypes[key];
const { control } = argType;
- const controlType = control && control.type.toLowerCase();
+ const controlType =
+ control && typeof control === 'object' && 'type' in control && control.type?.toLowerCase();
const argValue = storyArgs[key];
switch (controlType) {
case 'date':
diff --git a/code/renderers/vue3/src/docs/__snapshots__/extractArgTypes.test.ts.snap b/code/renderers/vue3/src/docs/__snapshots__/extractArgTypes.test.ts.snap
index 164f338725e3..cbe74a47b7d6 100644
--- a/code/renderers/vue3/src/docs/__snapshots__/extractArgTypes.test.ts.snap
+++ b/code/renderers/vue3/src/docs/__snapshots__/extractArgTypes.test.ts.snap
@@ -66,7 +66,7 @@ exports[`extractArgTypes (vue-docgen-api) > should extract events for component
"name": "bar",
"table": {
"category": "events",
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"summary": "{ year: number; title?: any }",
@@ -86,7 +86,7 @@ exports[`extractArgTypes (vue-docgen-api) > should extract events for component
"name": "baz",
"table": {
"category": "events",
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": undefined,
},
@@ -109,7 +109,7 @@ exports[`extractArgTypes (vue-docgen-api) > should extract expose for component
"name": "count",
"table": {
"category": "expose",
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": undefined,
},
@@ -126,7 +126,7 @@ exports[`extractArgTypes (vue-docgen-api) > should extract expose for component
"name": "label",
"table": {
"category": "expose",
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": undefined,
},
@@ -510,7 +510,7 @@ exports[`extractArgTypes (vue-docgen-api) > should extract props for component 2
"name": "array",
"table": {
"category": "props",
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"summary": "MyNestedProps[]",
@@ -527,7 +527,7 @@ exports[`extractArgTypes (vue-docgen-api) > should extract props for component 2
"name": "arrayOptional",
"table": {
"category": "props",
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"summary": "MyNestedProps[]",
@@ -562,7 +562,7 @@ exports[`extractArgTypes (vue-docgen-api) > should extract props for component 2
"name": "baz",
"table": {
"category": "props",
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"summary": "boolean",
@@ -578,7 +578,7 @@ exports[`extractArgTypes (vue-docgen-api) > should extract props for component 2
"name": "enumValue",
"table": {
"category": "props",
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"summary": "MyEnum",
@@ -595,7 +595,7 @@ exports[`extractArgTypes (vue-docgen-api) > should extract props for component 2
"name": "foo",
"table": {
"category": "props",
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"summary": "string",
@@ -611,7 +611,7 @@ exports[`extractArgTypes (vue-docgen-api) > should extract props for component 2
"name": "inlined",
"table": {
"category": "props",
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"summary": "{ foo: string }",
@@ -628,7 +628,7 @@ exports[`extractArgTypes (vue-docgen-api) > should extract props for component 2
"name": "literalFromContext",
"table": {
"category": "props",
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"summary": "MyCategories",
@@ -645,7 +645,7 @@ exports[`extractArgTypes (vue-docgen-api) > should extract props for component 2
"name": "nested",
"table": {
"category": "props",
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"summary": "MyNestedProps",
@@ -662,7 +662,7 @@ exports[`extractArgTypes (vue-docgen-api) > should extract props for component 2
"name": "nestedIntersection",
"table": {
"category": "props",
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"summary": "MyNestedProps & {
@@ -684,7 +684,7 @@ exports[`extractArgTypes (vue-docgen-api) > should extract props for component 2
"name": "nestedOptional",
"table": {
"category": "props",
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"summary": "MyNestedProps | MyIgnoredNestedProps",
@@ -710,7 +710,7 @@ exports[`extractArgTypes (vue-docgen-api) > should extract props for component 2
"name": "recursive",
"table": {
"category": "props",
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"summary": "MyNestedRecursiveProps",
@@ -746,7 +746,7 @@ exports[`extractArgTypes (vue-docgen-api) > should extract props for component 2
"name": "union",
"table": {
"category": "props",
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"summary": "string | number",
@@ -770,7 +770,7 @@ exports[`extractArgTypes (vue-docgen-api) > should extract props for component 2
"name": "unionOptional",
"table": {
"category": "props",
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"summary": "string | number | boolean",
@@ -802,7 +802,7 @@ exports[`extractArgTypes (vue-docgen-api) > should extract slots for component 1
"name": "default",
"table": {
"category": "slots",
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"summary": "{ num: unknown }",
@@ -819,7 +819,7 @@ exports[`extractArgTypes (vue-docgen-api) > should extract slots for component 1
"name": "named",
"table": {
"category": "slots",
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"summary": "{ str: unknown }",
@@ -836,7 +836,7 @@ exports[`extractArgTypes (vue-docgen-api) > should extract slots for component 1
"name": "no-bind",
"table": {
"category": "slots",
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": undefined,
},
@@ -851,7 +851,7 @@ exports[`extractArgTypes (vue-docgen-api) > should extract slots for component 1
"name": "vbind",
"table": {
"category": "slots",
- "defaultValue": null,
+ "defaultValue": undefined,
"jsDocTags": undefined,
"type": {
"summary": "{ num: unknown; str: unknown }",
diff --git a/code/renderers/vue3/src/docs/extractArgTypes.ts b/code/renderers/vue3/src/docs/extractArgTypes.ts
index d466cea2e1cd..66bc7efcf7da 100644
--- a/code/renderers/vue3/src/docs/extractArgTypes.ts
+++ b/code/renderers/vue3/src/docs/extractArgTypes.ts
@@ -127,7 +127,7 @@ export const extractFromVueDocgenApi = (
type: sbType ? { ...sbType, required } : { name: 'other', value: type ?? '' },
table: {
type: type ? { summary: type } : undefined,
- defaultValue: extractedProp?.propDef.defaultValue,
+ defaultValue: extractedProp?.propDef.defaultValue ?? undefined,
jsDocTags: extractedProp?.propDef.jsDocTags,
category: section,
},
diff --git a/code/ui/.storybook/main.ts b/code/ui/.storybook/main.ts
index 4025715324d0..03dea1aea08d 100644
--- a/code/ui/.storybook/main.ts
+++ b/code/ui/.storybook/main.ts
@@ -8,9 +8,12 @@ const isBlocksOnly = process.env.STORYBOOK_BLOCKS_ONLY === 'true';
const allStories = [
{
directory: '../manager/src',
- files: '**/*.stories.@(js|jsx|mjs|ts|tsx|mdx)',
titlePrefix: '@manager',
},
+ {
+ directory: '../../lib/preview-api/src',
+ titlePrefix: '@preview',
+ },
{
directory: '../components/src/components',
titlePrefix: '@components',
@@ -19,6 +22,14 @@ const allStories = [
directory: '../blocks/src',
titlePrefix: '@blocks',
},
+ {
+ directory: '../../addons/controls/src', // TODO other addons?
+ titlePrefix: '@addons/controls',
+ },
+ {
+ directory: '../../addons/onboarding/src',
+ titlePrefix: '@addons/onboarding',
+ },
];
/**
diff --git a/code/ui/.storybook/manager.tsx b/code/ui/.storybook/manager.tsx
index 7cdbda8d32e9..1ac61cf4d375 100644
--- a/code/ui/.storybook/manager.tsx
+++ b/code/ui/.storybook/manager.tsx
@@ -1,5 +1,4 @@
-import React from 'react';
-import { addons, types } from '@storybook/manager-api';
+import { addons } from '@storybook/manager-api';
import startCase from 'lodash/startCase.js';
addons.setConfig({
diff --git a/code/ui/blocks/package.json b/code/ui/blocks/package.json
index b09a79267482..3369d91ea08e 100644
--- a/code/ui/blocks/package.json
+++ b/code/ui/blocks/package.json
@@ -48,7 +48,7 @@
"@storybook/client-logger": "workspace:*",
"@storybook/components": "workspace:*",
"@storybook/core-events": "workspace:*",
- "@storybook/csf": "^0.1.2",
+ "@storybook/csf": "^0.1.6",
"@storybook/docs-tools": "workspace:*",
"@storybook/global": "^5.0.0",
"@storybook/icons": "^1.2.5",
diff --git a/code/ui/blocks/src/blocks/Subtitle.stories.tsx b/code/ui/blocks/src/blocks/Subtitle.stories.tsx
new file mode 100644
index 000000000000..4fe4a2ef6a19
--- /dev/null
+++ b/code/ui/blocks/src/blocks/Subtitle.stories.tsx
@@ -0,0 +1,104 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import React from 'react';
+import { Subtitle } from './Subtitle';
+import * as DefaultButtonStories from '../examples/Button.stories';
+import * as ButtonStoriesWithMetaSubtitleAsBoth from '../examples/ButtonWithMetaSubtitleAsBoth.stories';
+import * as ButtonStoriesWithMetaSubtitleAsComponentSubtitle from '../examples/ButtonWithMetaSubtitleAsComponentSubtitle.stories';
+import * as ButtonStoriesWithMetaSubtitleAsDocsSubtitle from '../examples/ButtonWithMetaSubtitleAsDocsSubtitle.stories';
+
+const meta: Meta = {
+ component: Subtitle,
+ parameters: {
+ controls: {
+ include: [],
+ hideNoControlsWarning: true,
+ },
+ // workaround for https://github.com/storybookjs/storybook/issues/20505
+ docs: { source: { type: 'code' } },
+ attached: false,
+ docsStyles: true,
+ },
+};
+export default meta;
+
+type Story = StoryObj;
+
+export const OfCSFFileAsBoth: Story = {
+ args: {
+ of: ButtonStoriesWithMetaSubtitleAsBoth,
+ },
+ parameters: {
+ relativeCsfPaths: ['../examples/ButtonWithMetaSubtitleAsBoth.stories'],
+ },
+};
+export const OfCSFFileAsComponentSubtitle: Story = {
+ name: 'Of CSF File As parameters.componentSubtitle',
+ args: {
+ of: ButtonStoriesWithMetaSubtitleAsComponentSubtitle,
+ },
+ parameters: {
+ relativeCsfPaths: ['../examples/ButtonWithMetaSubtitleAsComponentSubtitle.stories'],
+ },
+};
+export const OfCSFFileAsDocsSubtitle: Story = {
+ name: 'Of CSF File As parameters.docs.subtitle',
+ args: {
+ of: ButtonStoriesWithMetaSubtitleAsDocsSubtitle,
+ },
+ parameters: {
+ relativeCsfPaths: ['../examples/ButtonWithMetaSubtitleAsDocsSubtitle.stories'],
+ },
+};
+export const OfMetaAsBoth: Story = {
+ args: {
+ of: ButtonStoriesWithMetaSubtitleAsBoth.default,
+ },
+ parameters: {
+ relativeCsfPaths: ['../examples/ButtonWithMetaSubtitleAsBoth.stories'],
+ },
+};
+export const OfMetaAsComponentSubtitle: Story = {
+ name: 'Of Meta As parameters.componentSubtitle',
+ args: {
+ of: ButtonStoriesWithMetaSubtitleAsComponentSubtitle.default,
+ },
+ parameters: {
+ relativeCsfPaths: ['../examples/ButtonWithMetaSubtitleAsComponentSubtitle.stories'],
+ },
+};
+export const OfMetaAsDocsSubtitle: Story = {
+ name: 'Of Meta As parameters.docs.subtitle',
+ args: {
+ of: ButtonStoriesWithMetaSubtitleAsDocsSubtitle.default,
+ },
+ parameters: {
+ relativeCsfPaths: ['../examples/ButtonWithMetaSubtitleAsDocsSubtitle.stories'],
+ },
+};
+export const DefaultAttached: Story = {
+ parameters: { relativeCsfPaths: ['../examples/Button.stories'], attached: true },
+};
+export const OfUndefinedAttached: Story = {
+ args: {
+ // @ts-expect-error this is supposed to be undefined
+ // eslint-disable-next-line import/namespace
+ of: DefaultButtonStories.NotDefined,
+ },
+ parameters: {
+ chromatic: { disableSnapshot: true },
+ relativeCsfPaths: ['../examples/Button.stories'],
+ attached: true,
+ },
+ decorators: [(s) => (window?.navigator.userAgent.match(/StorybookTestRunner/) ?
: s())],
+};
+export const OfStringMetaAttached: Story = {
+ name: 'Of "meta" Attached',
+ args: {
+ of: 'meta',
+ },
+ parameters: { relativeCsfPaths: ['../examples/Button.stories'], attached: true },
+};
+export const Children: Story = {
+ parameters: { relativeCsfPaths: ['../examples/Button.stories'], attached: true },
+ render: () => This subtitle is a string passed as a children ,
+};
diff --git a/code/ui/blocks/src/blocks/Subtitle.tsx b/code/ui/blocks/src/blocks/Subtitle.tsx
index 143543cb27fb..9b7556e9c7c6 100644
--- a/code/ui/blocks/src/blocks/Subtitle.tsx
+++ b/code/ui/blocks/src/blocks/Subtitle.tsx
@@ -1,15 +1,40 @@
import type { FunctionComponent, ReactNode } from 'react';
-import React, { useContext } from 'react';
+import React from 'react';
+import { deprecate } from '@storybook/client-logger';
+
import { Subtitle as PureSubtitle } from '../components';
-import { DocsContext } from './DocsContext';
+import type { Of } from './useOf';
+import { useOf } from './useOf';
interface SubtitleProps {
children?: ReactNode;
+ /**
+ * Specify where to get the subtitle from.
+ * If not specified, the subtitle will be extracted from the meta of the attached CSF file.
+ */
+ of?: Of;
}
-export const Subtitle: FunctionComponent = ({ children }) => {
- const docsContext = useContext(DocsContext);
- const content = children || docsContext.storyById().parameters?.componentSubtitle;
+const DEPRECATION_MIGRATION_LINK =
+ 'https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#subtitle-block-and-parameterscomponentsubtitle';
+
+export const Subtitle: FunctionComponent = (props) => {
+ const { of, children } = props;
+
+ if ('of' in props && of === undefined) {
+ throw new Error('Unexpected `of={undefined}`, did you mistype a CSF file reference?');
+ }
+
+ const { preparedMeta } = useOf(of || 'meta', ['meta']);
+ const { componentSubtitle, docs } = preparedMeta.parameters || {};
+
+ if (componentSubtitle) {
+ deprecate(
+ `Using 'parameters.componentSubtitle' property to subtitle stories is deprecated. See ${DEPRECATION_MIGRATION_LINK}`
+ );
+ }
+
+ const content = children || docs?.subtitle || componentSubtitle;
return content ? (
{content}
diff --git a/code/ui/blocks/src/blocks/Title.stories.tsx b/code/ui/blocks/src/blocks/Title.stories.tsx
new file mode 100644
index 000000000000..a75b6ef72d98
--- /dev/null
+++ b/code/ui/blocks/src/blocks/Title.stories.tsx
@@ -0,0 +1,55 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import { Title } from './Title';
+import * as DefaultButtonStories from '../examples/Button.stories';
+
+const meta: Meta = {
+ component: Title,
+ title: 'Blocks/Title',
+ parameters: {
+ controls: {
+ include: [],
+ hideNoControlsWarning: true,
+ },
+ // workaround for https://github.com/storybookjs/storybook/issues/20505
+ docs: { source: { type: 'code' } },
+ attached: false,
+ docsStyles: true,
+ },
+};
+export default meta;
+
+type Story = StoryObj;
+
+export const OfCSFFile: Story = {
+ args: {
+ of: DefaultButtonStories,
+ },
+ parameters: { relativeCsfPaths: ['../examples/Button.stories'] },
+};
+
+export const OfMeta: Story = {
+ args: {
+ of: DefaultButtonStories,
+ },
+ parameters: { relativeCsfPaths: ['../examples/Button.stories'] },
+};
+
+export const OfStringMetaAttached: Story = {
+ name: 'Of attached "meta"',
+ args: {
+ of: 'meta',
+ },
+ parameters: { relativeCsfPaths: ['../examples/Button.stories'], attached: true },
+};
+
+export const Children: Story = {
+ args: {
+ children: 'Title as children',
+ },
+ parameters: { relativeCsfPaths: ['../examples/Button.stories'], attached: false },
+};
+
+export const DefaultAttached: Story = {
+ args: {},
+ parameters: { relativeCsfPaths: ['../examples/Button.stories'], attached: true },
+};
diff --git a/code/ui/blocks/src/blocks/Title.tsx b/code/ui/blocks/src/blocks/Title.tsx
index 1f52fb2cc179..55b85ebad717 100644
--- a/code/ui/blocks/src/blocks/Title.tsx
+++ b/code/ui/blocks/src/blocks/Title.tsx
@@ -1,10 +1,20 @@
import type { ComponentTitle } from '@storybook/types';
import type { FunctionComponent, ReactNode } from 'react';
-import React, { useContext } from 'react';
+import React from 'react';
import { Title as PureTitle } from '../components';
-import { DocsContext } from './DocsContext';
+import type { Of } from './useOf';
+import { useOf } from './useOf';
interface TitleProps {
+ /**
+ * Specify where to get the title from. Must be a CSF file's default export.
+ * If not specified, the title will be read from children, or extracted from the meta of the attached CSF file.
+ */
+ of?: Of;
+
+ /**
+ * Specify content to display as the title.
+ */
children?: ReactNode;
}
@@ -12,12 +22,27 @@ const STORY_KIND_PATH_SEPARATOR = /\s*\/\s*/;
export const extractTitle = (title: ComponentTitle) => {
const groups = title.trim().split(STORY_KIND_PATH_SEPARATOR);
- return (groups && groups[groups.length - 1]) || title;
+ return groups?.[groups?.length - 1] || title;
};
-export const Title: FunctionComponent = ({ children }) => {
- const context = useContext(DocsContext);
- const content = children || extractTitle(context.storyById().title);
+export const Title: FunctionComponent = (props) => {
+ const { children, of } = props;
+
+ if ('of' in props && of === undefined) {
+ throw new Error('Unexpected `of={undefined}`, did you mistype a CSF file reference?');
+ }
+
+ let preparedMeta;
+ try {
+ preparedMeta = useOf(of || 'meta', ['meta']).preparedMeta;
+ } catch (error) {
+ if (children && !error.message.includes('did you forget to use ?')) {
+ // ignore error about unattached CSF since we can still render children
+ throw error;
+ }
+ }
+
+ const content = children || extractTitle(preparedMeta.title);
return content ? {content} : null;
};
diff --git a/code/ui/blocks/src/blocks/mdx.tsx b/code/ui/blocks/src/blocks/mdx.tsx
index 453f5c9ff8ed..fe9c8c24f5c9 100644
--- a/code/ui/blocks/src/blocks/mdx.tsx
+++ b/code/ui/blocks/src/blocks/mdx.tsx
@@ -96,45 +96,43 @@ export const AnchorMdx: FC> = (props) => {
const { href, target, children, ...rest } = props;
const context = useContext(DocsContext);
- if (href) {
- // Enable scrolling for in-page anchors.
- if (href.startsWith('#')) {
- return {children} ;
- }
-
- // Links to other pages of SB should use the base URL of the top level iframe instead of the base URL of the preview iframe.
- if (target !== '_blank' && !href.startsWith('https://')) {
- return (
- ) => {
- // Cmd/Ctrl/Shift/Alt + Click should trigger default browser behaviour. Same applies to non-left clicks
- const LEFT_BUTTON = 0;
- const isLeftClick =
- event.button === LEFT_BUTTON &&
- !event.altKey &&
- !event.ctrlKey &&
- !event.metaKey &&
- !event.shiftKey;
-
- if (isLeftClick) {
- event.preventDefault();
- // use the A element's href, which has been modified for
- // local paths without a `?path=` query param prefix
- navigate(context, event.currentTarget.getAttribute('href'));
- }
- }}
- target={target}
- {...rest}
- >
- {children}
-
- );
- }
+ // links to external locations don't need any modifications.
+ if (!href || target === '_blank' || /^https?:\/\//.test(href)) {
+ return ;
}
- // External URL dont need any modification.
- return ;
+ // Enable scrolling for in-page anchors.
+ if (href.startsWith('#')) {
+ return {children} ;
+ }
+
+ // Links to other pages of SB should use the base URL of the top level iframe instead of the base URL of the preview iframe.
+ return (
+ ) => {
+ // Cmd/Ctrl/Shift/Alt + Click should trigger default browser behaviour. Same applies to non-left clicks
+ const LEFT_BUTTON = 0;
+ const isLeftClick =
+ event.button === LEFT_BUTTON &&
+ !event.altKey &&
+ !event.ctrlKey &&
+ !event.metaKey &&
+ !event.shiftKey;
+
+ if (isLeftClick) {
+ event.preventDefault();
+ // use the A element's href, which has been modified for
+ // local paths without a `?path=` query param prefix
+ navigate(context, event.currentTarget.getAttribute('href'));
+ }
+ }}
+ target={target}
+ {...rest}
+ >
+ {children}
+
+ );
};
const SUPPORTED_MDX_HEADERS = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] as const;
diff --git a/code/ui/blocks/src/components/ArgsTable/ArgsTable.stories.tsx b/code/ui/blocks/src/components/ArgsTable/ArgsTable.stories.tsx
index 39ed63dc6299..159b65b5df03 100644
--- a/code/ui/blocks/src/components/ArgsTable/ArgsTable.stories.tsx
+++ b/code/ui/blocks/src/components/ArgsTable/ArgsTable.stories.tsx
@@ -52,27 +52,6 @@ export const Compact = {
args: { ...Normal.args, compact: true },
};
-const AddonPanelLayout = styled.div(({ theme }) => ({
- fontSize: theme.typography.size.s2 - 1,
- background: theme.background.content,
-}));
-
-export const InAddonPanel = {
- args: { ...Normal.args, inAddonPanel: true },
- decorators: [(storyFn: any) => {storyFn()} ],
-};
-
-export const InAddonPanelNoControls = {
- render: (args: any) => ,
- args: {
- rows: {
- stringType: { ...stringType, control: false },
- numberType: { ...numberType, control: false },
- },
- },
- decorators: InAddonPanel.decorators,
-};
-
export const Sections = {
args: {
rows: {
@@ -137,6 +116,33 @@ export const AllControls = {
},
};
+const AddonPanelLayout = styled.div(({ theme }) => ({
+ fontSize: theme.typography.size.s2 - 1,
+ background: theme.background.content,
+}));
+
+export const InAddonPanel = {
+ args: {
+ ...Normal.args,
+ inAddonPanel: true,
+ rows: SectionsAndSubsections.args.rows,
+ },
+ decorators: [(storyFn: any) => {storyFn()} ],
+ parameters: {
+ layout: 'fullscreen',
+ },
+};
+
+export const InAddonPanelNoControls = {
+ ...InAddonPanel,
+ args: {
+ ...InAddonPanel.args,
+ rows: Object.fromEntries(
+ Object.entries(InAddonPanel.args.rows).map(([k, v]) => [k, { ...v, control: null }])
+ ),
+ },
+};
+
export const Error = {
args: {
error: ArgsTableError.NO_COMPONENT,
diff --git a/code/ui/blocks/src/components/ArgsTable/types.ts b/code/ui/blocks/src/components/ArgsTable/types.ts
index 653bf236acdb..7efb484fe6a9 100644
--- a/code/ui/blocks/src/components/ArgsTable/types.ts
+++ b/code/ui/blocks/src/components/ArgsTable/types.ts
@@ -46,11 +46,11 @@ export interface ArgType {
disable?: boolean;
subcategory?: string;
defaultValue?: {
- summary: string;
+ summary?: string;
detail?: string;
};
type?: {
- summary: string;
+ summary?: string;
detail?: string;
};
readonly?: boolean;
diff --git a/code/ui/blocks/src/controls/options/Checkbox.tsx b/code/ui/blocks/src/controls/options/Checkbox.tsx
index 6be088b111ba..7c883c21300c 100644
--- a/code/ui/blocks/src/controls/options/Checkbox.tsx
+++ b/code/ui/blocks/src/controls/options/Checkbox.tsx
@@ -27,7 +27,7 @@ const Wrapper = styled.div<{ isInline: boolean }>(
},
},
(props) => {
- if ([props['aria-readonly']]) {
+ if (props['aria-readonly'] === 'true') {
return {
input: {
cursor: 'not-allowed',
diff --git a/code/ui/blocks/src/controls/options/Radio.tsx b/code/ui/blocks/src/controls/options/Radio.tsx
index d3bc514f3361..76c3239e747a 100644
--- a/code/ui/blocks/src/controls/options/Radio.tsx
+++ b/code/ui/blocks/src/controls/options/Radio.tsx
@@ -27,7 +27,7 @@ const Wrapper = styled.div<{ isInline: boolean }>(
},
},
(props) => {
- if ([props['aria-readonly']]) {
+ if (props['aria-readonly'] === 'true') {
return {
input: {
cursor: 'not-allowed',
diff --git a/code/ui/blocks/src/controls/options/SelectOptions.stories.tsx b/code/ui/blocks/src/controls/options/SelectOptions.stories.tsx
index d417c24bd313..6d52b80f625e 100644
--- a/code/ui/blocks/src/controls/options/SelectOptions.stories.tsx
+++ b/code/ui/blocks/src/controls/options/SelectOptions.stories.tsx
@@ -20,7 +20,7 @@ const argTypeMultiSelect = {
options: arrayOptions,
},
},
-};
+} as const;
const meta = {
title: 'Controls/Options/Select',
diff --git a/code/ui/blocks/src/examples/Button.stories.tsx b/code/ui/blocks/src/examples/Button.stories.tsx
index e5fc5b2e3457..a49f88f5d8f8 100644
--- a/code/ui/blocks/src/examples/Button.stories.tsx
+++ b/code/ui/blocks/src/examples/Button.stories.tsx
@@ -17,6 +17,9 @@ const meta = {
notes: 'These are notes for the Button stories',
info: 'This is info for the Button stories',
jsx: { useBooleanShorthandSyntax: false },
+ docs: {
+ subtitle: 'This is the subtitle for the Button stories',
+ },
},
} satisfies Meta;
diff --git a/code/ui/blocks/src/examples/ButtonWithMetaSubtitleAsBoth.stories.tsx b/code/ui/blocks/src/examples/ButtonWithMetaSubtitleAsBoth.stories.tsx
new file mode 100644
index 000000000000..5b4235c07c57
--- /dev/null
+++ b/code/ui/blocks/src/examples/ButtonWithMetaSubtitleAsBoth.stories.tsx
@@ -0,0 +1,29 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import { Button } from './Button';
+
+const meta = {
+ title: 'examples/Button with Meta Subtitle in Both',
+ component: Button,
+ argTypes: {
+ backgroundColor: { control: 'color' },
+ },
+ parameters: {
+ // Stop *this* story from being stacked in Chromatic
+ theme: 'default',
+ // this is to test the deprecated features of the Subtitle block
+ componentSubtitle: 'This subtitle is set in parameters.componentSubtitle',
+ docs: {
+ subtitle: 'This subtitle is set in parameters.docs.subtitle',
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const WithMetaSubtitleAsBoth: Story = {
+ args: {
+ primary: true,
+ label: 'Button',
+ },
+};
diff --git a/code/ui/blocks/src/examples/ButtonWithMetaSubtitleAsComponentSubtitle.stories.tsx b/code/ui/blocks/src/examples/ButtonWithMetaSubtitleAsComponentSubtitle.stories.tsx
new file mode 100644
index 000000000000..57a106340421
--- /dev/null
+++ b/code/ui/blocks/src/examples/ButtonWithMetaSubtitleAsComponentSubtitle.stories.tsx
@@ -0,0 +1,26 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import { Button } from './Button';
+
+const meta = {
+ title: 'examples/Button with Meta Subtitle in componentSubtitle',
+ component: Button,
+ argTypes: {
+ backgroundColor: { control: 'color' },
+ },
+ parameters: {
+ // Stop *this* story from being stacked in Chromatic
+ theme: 'default',
+ // this is to test the deprecated features of the Subtitle block
+ componentSubtitle: 'This subtitle is set in parameters.componentSubtitle',
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const WithMetaSubtitleInComponentSubtitle: Story = {
+ args: {
+ primary: true,
+ label: 'Button',
+ },
+};
diff --git a/code/ui/blocks/src/examples/ButtonWithMetaSubtitleAsDocsSubtitle.stories.tsx b/code/ui/blocks/src/examples/ButtonWithMetaSubtitleAsDocsSubtitle.stories.tsx
new file mode 100644
index 000000000000..3df3110baf6c
--- /dev/null
+++ b/code/ui/blocks/src/examples/ButtonWithMetaSubtitleAsDocsSubtitle.stories.tsx
@@ -0,0 +1,27 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import { Button } from './Button';
+
+const meta = {
+ title: 'examples/Button with Meta Subtitle in docs.subtitle',
+ component: Button,
+ argTypes: {
+ backgroundColor: { control: 'color' },
+ },
+ parameters: {
+ // Stop *this* story from being stacked in Chromatic
+ theme: 'default',
+ docs: {
+ subtitle: 'This subtitle is set in parameters.docs.subtitle',
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const WithMetaSubtitleInDocsSubtitle: Story = {
+ args: {
+ primary: true,
+ label: 'Button',
+ },
+};
diff --git a/code/ui/blocks/src/examples/EmptyExample.tsx b/code/ui/blocks/src/examples/EmptyExample.tsx
index d9ad80b7a120..a1b48922f303 100644
--- a/code/ui/blocks/src/examples/EmptyExample.tsx
+++ b/code/ui/blocks/src/examples/EmptyExample.tsx
@@ -2,7 +2,7 @@ import React from 'react';
export const EmptyExample = ({}) => (
- This component is not intended to render anything, it simply serves a something to hang
+ This component is not intended to render anything, it simply serves as something to hang
parameters off
);
diff --git a/code/ui/components/package.json b/code/ui/components/package.json
index 613e78bbf998..b1ceb03faa85 100644
--- a/code/ui/components/package.json
+++ b/code/ui/components/package.json
@@ -59,9 +59,10 @@
"prep": "node --loader ../../../scripts/node_modules/esbuild-register/loader.js -r ../../../scripts/node_modules/esbuild-register/register.js ../../../scripts/prepare/bundle.ts"
},
"dependencies": {
+ "@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-slot": "^1.0.2",
"@storybook/client-logger": "workspace:*",
- "@storybook/csf": "^0.1.2",
+ "@storybook/csf": "^0.1.6",
"@storybook/global": "^5.0.0",
"@storybook/icons": "^1.2.5",
"@storybook/theming": "workspace:*",
diff --git a/code/ui/components/src/components/Badge/Badge.stories.tsx b/code/ui/components/src/components/Badge/Badge.stories.tsx
index 530b4f368ac2..974dc64f8be1 100644
--- a/code/ui/components/src/components/Badge/Badge.stories.tsx
+++ b/code/ui/components/src/components/Badge/Badge.stories.tsx
@@ -1,12 +1,16 @@
+import type { Meta, StoryObj } from '@storybook/react';
import { Badge } from './Badge';
-export default {
+const meta = {
component: Badge,
-};
+} satisfies Meta;
-export const Default = { args: { children: 'Default' } };
-export const Positive = { args: { status: 'positive', children: 'Positive' } };
-export const Negative = { args: { status: 'negative', children: 'Negative' } };
-export const Neutral = { args: { status: 'neutral', children: 'Neutral' } };
-export const Warning = { args: { status: 'warning', children: 'Warning' } };
-export const Critical = { args: { status: 'critical', children: 'Critical' } };
+export default meta;
+type Story = StoryObj;
+
+export const Default = { args: { children: 'Default' } } satisfies Story;
+export const Positive = { args: { status: 'positive', children: 'Positive' } } satisfies Story;
+export const Negative = { args: { status: 'negative', children: 'Negative' } } satisfies Story;
+export const Neutral = { args: { status: 'neutral', children: 'Neutral' } } satisfies Story;
+export const Warning = { args: { status: 'warning', children: 'Warning' } } satisfies Story;
+export const Critical = { args: { status: 'critical', children: 'Critical' } } satisfies Story;
diff --git a/code/ui/components/src/components/Badge/Badge.tsx b/code/ui/components/src/components/Badge/Badge.tsx
index f9d1c9b91ebb..51bcee12e2ef 100644
--- a/code/ui/components/src/components/Badge/Badge.tsx
+++ b/code/ui/components/src/components/Badge/Badge.tsx
@@ -80,7 +80,7 @@ const BadgeWrapper = styled.div(
);
export interface BadgeProps {
- status: 'positive' | 'negative' | 'neutral' | 'warning' | 'critical';
+ status?: 'positive' | 'negative' | 'neutral' | 'warning' | 'critical';
children?: React.ReactNode;
}
diff --git a/code/addons/onboarding/src/components/Modal/Modal.stories.tsx b/code/ui/components/src/components/Modal/Modal.stories.tsx
similarity index 55%
rename from code/addons/onboarding/src/components/Modal/Modal.stories.tsx
rename to code/ui/components/src/components/Modal/Modal.stories.tsx
index 527aa87d4323..658683e6368b 100644
--- a/code/addons/onboarding/src/components/Modal/Modal.stories.tsx
+++ b/code/ui/components/src/components/Modal/Modal.stories.tsx
@@ -3,18 +3,20 @@ import type { Meta, StoryObj } from '@storybook/react';
import { userEvent, within, expect } from '@storybook/test';
import { Modal } from './Modal';
+import { Button } from '../Button/Button';
-const meta: Meta = {
+type Story = StoryObj;
+
+const meta = {
component: Modal,
decorators: [(storyFn) => {storyFn()}
],
-};
+} satisfies Meta;
export default meta;
-type Story = StoryObj;
-
export const Default: Story = {
args: {
+ children: undefined,
width: undefined,
height: undefined,
},
@@ -24,12 +26,10 @@ export const Default: Story = {
return (
<>
- {({ Close }) => (
-
-
Hello world!
-
setOpen(false)}>Close
-
- )}
+
+
Hello world!
+
setOpen(false)}>Close
+
setOpen(true)}>Open modal
>
@@ -54,12 +54,10 @@ export const FixedWidth: Story = {
return (
<>
- {({ Close }) => (
-
-
Hello world!
-
setOpen(false)}>Close
-
- )}
+
+
Hello world!
+
setOpen(false)}>Close
+
setOpen(true)}>Open modal
>
@@ -84,12 +82,10 @@ export const FixedHeight: Story = {
return (
<>
- {({ Close }) => (
-
-
Hello world!
-
setOpen(false)}>Close
-
- )}
+
+
Hello world!
+
setOpen(false)}>Close
+
setOpen(true)}>Open modal
>
@@ -115,12 +111,10 @@ export const FixedWidthAndHeight: Story = {
return (
<>
- {({ Close }) => (
-
-
Hello world!
-
setOpen(false)}>Close
-
- )}
+
+
Hello world!
+
setOpen(false)}>Close
+
setOpen(true)}>Open modal
>
@@ -133,3 +127,48 @@ export const FixedWidthAndHeight: Story = {
await expect(canvas.findByText('Hello world!')).resolves.toBeInTheDocument();
},
};
+
+export const StyledComponents: Story = {
+ args: {
+ ...Default.args,
+ width: 500,
+ },
+ render: (props) => {
+ const [isOpen, setOpen] = useState(false);
+
+ return (
+ <>
+
+
+
+ Hello
+ Lorem ipsum dolor sit amet.
+
+
+
+ One
+ Two
+
+ Right
+
+ Another section
+
+ Save
+
+ Cancel
+
+
+
+ Oops. Something went wrong.
+
+ setOpen(true)}>Open modal
+ >
+ );
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement.parentElement!);
+ const button = canvas.getAllByText('Open modal')[0];
+ await userEvent.click(button);
+ await expect(canvas.findByText('Hello')).resolves.toBeInTheDocument();
+ },
+};
diff --git a/code/ui/components/src/components/Modal/Modal.styled.tsx b/code/ui/components/src/components/Modal/Modal.styled.tsx
new file mode 100644
index 000000000000..b60e512b74ab
--- /dev/null
+++ b/code/ui/components/src/components/Modal/Modal.styled.tsx
@@ -0,0 +1,135 @@
+import { keyframes, styled } from '@storybook/theming';
+import * as Dialog from '@radix-ui/react-dialog';
+import type { ComponentProps } from 'react';
+import React from 'react';
+
+import { IconButton } from '../IconButton/IconButton';
+import { CrossIcon } from '@storybook/icons';
+
+const fadeIn = keyframes({
+ from: { opacity: 0 },
+ to: { opacity: 1 },
+});
+
+const expand = keyframes({
+ from: { maxHeight: 0 },
+ to: {},
+});
+
+const zoomIn = keyframes({
+ from: {
+ opacity: 0,
+ transform: 'translate(-50%, -50%) scale(0.9)',
+ },
+ to: {
+ opacity: 1,
+ transform: 'translate(-50%, -50%) scale(1)',
+ },
+});
+
+export const Overlay = styled.div({
+ backgroundColor: 'rgba(27, 28, 29, 0.2)',
+ position: 'fixed',
+ inset: 0,
+ width: '100%',
+ height: '100%',
+ zIndex: 10,
+ animation: `${fadeIn} 200ms`,
+});
+
+export const Container = styled.div<{ width?: number; height?: number }>(
+ ({ theme, width, height }) => ({
+ backgroundColor: theme.background.bar,
+ borderRadius: 6,
+ boxShadow: `rgba(255, 255, 255, 0.05) 0 0 0 1px inset, rgba(14, 18, 22, 0.35) 0px 10px 38px -10px`,
+ position: 'fixed',
+ top: '50%',
+ left: '50%',
+ transform: 'translate(-50%, -50%)',
+ width: width ?? 740,
+ height: height ?? 'auto',
+ maxWidth: 'calc(100% - 40px)',
+ maxHeight: '85vh',
+ overflow: 'hidden',
+ zIndex: 11,
+ animation: `${zoomIn} 200ms`,
+
+ '&:focus-visible': {
+ outline: 'none',
+ },
+ })
+);
+
+export const CloseButton = (props: React.ComponentProps) => (
+
+
+
+
+
+);
+
+export const Content = styled.div({
+ display: 'flex',
+ flexDirection: 'column',
+ margin: 16,
+ gap: 16,
+});
+
+export const Row = styled.div({
+ display: 'flex',
+ justifyContent: 'space-between',
+ gap: 16,
+});
+
+export const Col = styled.div({
+ display: 'flex',
+ flexDirection: 'column',
+ gap: 4,
+});
+
+export const Header = (props: React.ComponentProps) => (
+
+
+
+
+);
+
+export const Title = styled(Dialog.Title)(({ theme }) => ({
+ margin: 0,
+ fontSize: theme.typography.size.s3,
+ fontWeight: theme.typography.weight.bold,
+}));
+
+export const Description = styled(Dialog.Description)(({ theme }) => ({
+ margin: 0,
+ fontSize: theme.typography.size.s2,
+}));
+
+export const Actions = styled.div({
+ display: 'flex',
+ flexDirection: 'row-reverse',
+ gap: 8,
+});
+
+export const ErrorWrapper = styled.div(({ theme }) => ({
+ maxHeight: 100,
+ overflow: 'auto',
+ animation: `${expand} 300ms, ${fadeIn} 300ms`,
+ backgroundColor: theme.background.critical,
+ color: theme.color.lightest,
+ fontSize: theme.typography.size.s2,
+
+ '& > div': {
+ position: 'relative',
+ padding: '8px 16px',
+ },
+}));
+
+export const Error = ({
+ children,
+ ...props
+}: { children: React.ReactNode } & ComponentProps) => (
+
+ {children}
+
+);
diff --git a/code/addons/onboarding/src/components/Modal/Modal.tsx b/code/ui/components/src/components/Modal/Modal.tsx
similarity index 59%
rename from code/addons/onboarding/src/components/Modal/Modal.tsx
rename to code/ui/components/src/components/Modal/Modal.tsx
index 4c8bd9bec66d..ad5ddaa85b4e 100644
--- a/code/addons/onboarding/src/components/Modal/Modal.tsx
+++ b/code/ui/components/src/components/Modal/Modal.tsx
@@ -1,52 +1,51 @@
import React from 'react';
import * as Dialog from '@radix-ui/react-dialog';
-import { ContentWrapper, StyledOverlay } from './Modal.styled';
+import * as Components from './Modal.styled';
-type ContentProps = React.ComponentProps;
+type ContentProps = React.ComponentProps;
interface ModalProps extends Omit, 'children'> {
width?: number;
height?: number;
- children: (props: {
- Title: typeof Dialog.Title;
- Description: typeof Dialog.Description;
- Close: typeof Dialog.Close;
- }) => React.ReactNode;
+ children: React.ReactNode;
onEscapeKeyDown?: ContentProps['onEscapeKeyDown'];
onInteractOutside?: ContentProps['onInteractOutside'];
+ className?: string;
+ container?: HTMLElement;
}
export const initial = { opacity: 0 };
export const animate = { opacity: 1, transition: { duration: 0.3 } };
export const exit = { opacity: 0, transition: { duration: 0.3 } };
-export function Modal({
+function BaseModal({
children,
width,
height,
onEscapeKeyDown,
onInteractOutside = (ev) => ev.preventDefault(),
+ className,
+ container,
...rootProps
}: ModalProps) {
return (
-
+
-
+
-
- {children({
- Title: Dialog.Title,
- Description: Dialog.Description,
- Close: Dialog.Close,
- })}
-
+
+ {children}
+
+
);
}
+
+export const Modal = Object.assign(BaseModal, Components, { Dialog });
diff --git a/code/ui/components/src/components/ScrollArea/ScrollArea.tsx b/code/ui/components/src/components/ScrollArea/ScrollArea.tsx
index f3f965af8783..cd697523d44d 100644
--- a/code/ui/components/src/components/ScrollArea/ScrollArea.tsx
+++ b/code/ui/components/src/components/ScrollArea/ScrollArea.tsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { forwardRef } from 'react';
import { styled } from '@storybook/theming';
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
@@ -75,36 +75,35 @@ const ScrollAreaThumb = styled(ScrollAreaPrimitive.Thumb)(({ theme }) => ({
},
}));
-export const ScrollArea = ({
- children,
- horizontal = false,
- vertical = false,
- offset = 2,
- scrollbarSize = 6,
- className,
-}: ScrollAreaProps) => (
-
- {children}
- {horizontal && (
-
-
-
- )}
- {vertical && (
-
-
-
- )}
- {horizontal && vertical && }
-
+export const ScrollArea = forwardRef(
+ (
+ { children, horizontal = false, vertical = false, offset = 2, scrollbarSize = 6, className },
+ ref
+ ) => (
+
+ {children}
+ {horizontal && (
+
+
+
+ )}
+ {vertical && (
+
+
+
+ )}
+ {horizontal && vertical && }
+
+ )
);
+ScrollArea.displayName = 'ScrollArea';
diff --git a/code/ui/components/src/components/tabs/tabs.stories.tsx b/code/ui/components/src/components/tabs/tabs.stories.tsx
index a3c40fd8a9d9..658b994b1e2e 100644
--- a/code/ui/components/src/components/tabs/tabs.stories.tsx
+++ b/code/ui/components/src/components/tabs/tabs.stories.tsx
@@ -18,6 +18,11 @@ interface FibonacciMap {
[key: string]: number;
}
+function Counter() {
+ const [count, setCount] = React.useState(0);
+ return setCount((prev) => prev + 1)}>{count} ;
+}
+
function fibonacci(num: number, memo?: FibonacciMap): number {
if (!memo) {
memo = {};
@@ -376,3 +381,22 @@ export const StatelessWithCustomEmpty = {
/>
),
} satisfies StoryObj;
+
+export const StatefulWithStatefulPanel = {
+ render: (args) => {
+ const [update, setUpdate] = React.useState(0);
+ return (
+
+
setUpdate((prev) => prev + 1)}>Update
+
+
+
+
+
+
+
+
+
+ );
+ },
+} satisfies Story;
diff --git a/code/ui/components/src/components/tabs/tabs.tsx b/code/ui/components/src/components/tabs/tabs.tsx
index 5b0cbb2b5612..3d90fb3f9e58 100644
--- a/code/ui/components/src/components/tabs/tabs.tsx
+++ b/code/ui/components/src/components/tabs/tabs.tsx
@@ -145,18 +145,13 @@ export const Tabs: FC = memo(
emptyState,
showToolsWhenEmpty,
}) => {
- const idList = childrenToList(children)
- .map((i) => i.id)
- .join(',');
-
const list = useMemo(
() =>
childrenToList(children).map((i, index) => ({
...i,
active: selected ? i.id === selected : index === 0,
})),
- // eslint-disable-next-line react-hooks/exhaustive-deps -- we're using idList as a replacement for children
- [selected, idList]
+ [children, selected]
);
const { visibleList, tabBarRef, tabRefs, AddonTab } = useList(list);
diff --git a/code/ui/components/src/index.ts b/code/ui/components/src/index.ts
index ffe4a08d699f..372a684c315d 100644
--- a/code/ui/components/src/index.ts
+++ b/code/ui/components/src/index.ts
@@ -42,6 +42,7 @@ export { createCopyToClipboardFunction } from './components/syntaxhighlighter/sy
// UI
export { ActionBar } from './components/ActionBar/ActionBar';
+export { Modal } from './components/Modal/Modal';
export { Spaced } from './components/spaced/Spaced';
export { Placeholder } from './components/placeholder/placeholder';
export { ScrollArea } from './components/ScrollArea/ScrollArea';
diff --git a/code/ui/manager/package.json b/code/ui/manager/package.json
index a5ccc976a202..0c8e86d7bdaa 100644
--- a/code/ui/manager/package.json
+++ b/code/ui/manager/package.json
@@ -85,12 +85,13 @@
"@storybook/test": "workspace:*",
"@storybook/theming": "workspace:*",
"@storybook/types": "workspace:*",
+ "@tanstack/react-virtual": "^3.3.0",
"@testing-library/react": "^11.2.2",
"@types/react-transition-group": "^4",
"@types/semver": "^7.3.4",
"browser-dtector": "^3.4.0",
"copy-to-clipboard": "^3.3.1",
- "downshift": "^6.0.15",
+ "downshift": "^9.0.4",
"fs-extra": "^11.1.0",
"fuse.js": "^3.6.1",
"lodash": "^4.17.21",
@@ -107,6 +108,7 @@
"resolve-from": "^5.0.0",
"semver": "^7.3.7",
"store2": "^2.14.2",
+ "telejson": "^7.2.0",
"ts-dedent": "^2.0.0",
"typescript": "^5.3.2"
},
diff --git a/code/ui/manager/src/components/notifications/NotificationItem.stories.tsx b/code/ui/manager/src/components/notifications/NotificationItem.stories.tsx
index af4c7fcc8e57..a3b87acdcf52 100644
--- a/code/ui/manager/src/components/notifications/NotificationItem.stories.tsx
+++ b/code/ui/manager/src/components/notifications/NotificationItem.stories.tsx
@@ -218,7 +218,7 @@ export const BookIconLongSubHeadline: Story = {
content: {
headline: 'Storybook has a book icon!',
subHeadline:
- 'Find out more! by clicking on on buttons and downloading some applications. Find out more! by clicking on buttons and downloading some applications',
+ 'Find out more! by clicking on buttons and downloading some applications. Find out more! by clicking on buttons and downloading some applications',
},
icon: ,
link: undefined,
diff --git a/code/ui/manager/src/components/notifications/NotificationItem.tsx b/code/ui/manager/src/components/notifications/NotificationItem.tsx
index d47f539a3f1c..f8e44fcd3660 100644
--- a/code/ui/manager/src/components/notifications/NotificationItem.tsx
+++ b/code/ui/manager/src/components/notifications/NotificationItem.tsx
@@ -81,7 +81,7 @@ const NotificationWithInteractiveStates = styled(Notification)(() => ({
'rgba(2,156,253,1) 0 0 0 1px inset, 0 1px 3px 0 rgba(30,167,253,0.5), 0 2px 5px 0 rgba(0,0,0,0.05), 0 5px 15px 0 rgba(0,0,0,0.1)',
},
}));
-const NotificationButton = NotificationWithInteractiveStates.withComponent('button');
+const NotificationButton = NotificationWithInteractiveStates.withComponent('div');
const NotificationLink = NotificationWithInteractiveStates.withComponent(Link);
const NotificationIconWrapper = styled.div(() => ({
diff --git a/code/ui/manager/src/components/sidebar/CreateNewStoryFileModal.tsx b/code/ui/manager/src/components/sidebar/CreateNewStoryFileModal.tsx
new file mode 100644
index 000000000000..0e16ea234750
--- /dev/null
+++ b/code/ui/manager/src/components/sidebar/CreateNewStoryFileModal.tsx
@@ -0,0 +1,219 @@
+import React, { useCallback, useDeferredValue, useEffect, useRef, useState } from 'react';
+import { CheckIcon } from '@storybook/icons';
+import type {
+ ArgTypesRequestPayload,
+ ArgTypesResponsePayload,
+ CreateNewStoryErrorPayload,
+ CreateNewStoryRequestPayload,
+ CreateNewStoryResponsePayload,
+ FileComponentSearchRequestPayload,
+ FileComponentSearchResponsePayload,
+ RequestData,
+ ResponseData,
+ SaveStoryRequestPayload,
+ SaveStoryResponsePayload,
+} from '@storybook/core-events';
+import {
+ ARGTYPES_INFO_REQUEST,
+ ARGTYPES_INFO_RESPONSE,
+ CREATE_NEW_STORYFILE_REQUEST,
+ CREATE_NEW_STORYFILE_RESPONSE,
+ FILE_COMPONENT_SEARCH_REQUEST,
+ FILE_COMPONENT_SEARCH_RESPONSE,
+ SAVE_STORY_REQUEST,
+ SAVE_STORY_RESPONSE,
+} from '@storybook/core-events';
+import type { RequestResponseError } from '@storybook/manager-api';
+import { addons, experimental_requestResponse, useStorybookApi } from '@storybook/manager-api';
+
+import { useDebounce } from '../../hooks/useDebounce';
+import type { NewStoryPayload, SearchResult } from './FileSearchList';
+import { extractSeededRequiredArgs, trySelectNewStory } from './FileSearchModal.utils';
+import { FileSearchModal } from './FileSearchModal';
+
+interface CreateNewStoryFileModalProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+const stringifyArgs = (args: Record) =>
+ JSON.stringify(args, (_, value) => {
+ if (typeof value === 'function') return '__sb_empty_function_arg__';
+ return value;
+ });
+
+export const CreateNewStoryFileModal = ({ open, onOpenChange }: CreateNewStoryFileModalProps) => {
+ const [isLoading, setLoading] = useState(false);
+ const [fileSearchQuery, setFileSearchQuery] = useState('');
+ const fileSearchQueryDebounced = useDebounce(fileSearchQuery, 600);
+ const fileSearchQueryDeferred = useDeferredValue(fileSearchQueryDebounced);
+ const emittedValue = useRef(null);
+ const [error, setError] = useState<{ selectedItemId?: number | string; error: string } | null>(
+ null
+ );
+ const api = useStorybookApi();
+
+ const [searchResults, setSearchResults] = useState(null);
+
+ const handleSuccessfullyCreatedStory = useCallback(
+ (componentExportName: string) => {
+ api.addNotification({
+ id: 'create-new-story-file-success',
+ content: {
+ headline: 'Story file created',
+ subHeadline: `${componentExportName} was created`,
+ },
+ duration: 8_000,
+ icon: ,
+ });
+
+ onOpenChange(false);
+ },
+ [api, onOpenChange]
+ );
+
+ const handleStoryAlreadyExists = useCallback(() => {
+ api.addNotification({
+ id: 'create-new-story-file-error',
+ content: {
+ headline: 'Story already exists',
+ subHeadline: `Successfully navigated to existing story`,
+ },
+ duration: 8_000,
+ icon: ,
+ });
+
+ onOpenChange(false);
+ }, [api, onOpenChange]);
+
+ const handleFileSearch = useCallback(() => {
+ setLoading(true);
+ const channel = addons.getChannel();
+
+ const set = (data: ResponseData) => {
+ const isLatestRequest = data.id === fileSearchQueryDeferred;
+
+ if (isLatestRequest) {
+ if (data.success) {
+ setSearchResults(data.payload.files);
+ } else {
+ setError({ error: data.error });
+ }
+
+ channel.off(FILE_COMPONENT_SEARCH_RESPONSE, set);
+ setLoading(false);
+ emittedValue.current = null;
+ }
+ };
+
+ channel.on(FILE_COMPONENT_SEARCH_RESPONSE, set);
+
+ if (fileSearchQueryDeferred !== '' && emittedValue.current !== fileSearchQueryDeferred) {
+ emittedValue.current = fileSearchQueryDeferred;
+ channel.emit(FILE_COMPONENT_SEARCH_REQUEST, {
+ id: fileSearchQueryDeferred,
+ payload: {},
+ } satisfies RequestData);
+ } else {
+ setSearchResults(null);
+ setLoading(false);
+ }
+
+ return () => {
+ channel.off(FILE_COMPONENT_SEARCH_RESPONSE, set);
+ };
+ }, [fileSearchQueryDeferred]);
+
+ const handleCreateNewStory = useCallback(
+ async ({
+ componentExportName,
+ componentFilePath,
+ componentIsDefaultExport,
+ componentExportCount,
+ selectedItemId,
+ }: NewStoryPayload) => {
+ try {
+ const channel = addons.getChannel();
+
+ const createNewStoryResult = await experimental_requestResponse<
+ CreateNewStoryRequestPayload,
+ CreateNewStoryResponsePayload,
+ CreateNewStoryErrorPayload
+ >(channel, CREATE_NEW_STORYFILE_REQUEST, CREATE_NEW_STORYFILE_RESPONSE, {
+ componentExportName,
+ componentFilePath,
+ componentIsDefaultExport,
+ componentExportCount,
+ });
+
+ setError(null);
+
+ const storyId = createNewStoryResult.storyId;
+
+ await trySelectNewStory(api.selectStory, storyId);
+
+ try {
+ const argTypesInfoResult = await experimental_requestResponse<
+ ArgTypesRequestPayload,
+ ArgTypesResponsePayload
+ >(channel, ARGTYPES_INFO_REQUEST, ARGTYPES_INFO_RESPONSE, {
+ storyId,
+ });
+
+ const argTypes = argTypesInfoResult.argTypes;
+
+ const requiredArgs = extractSeededRequiredArgs(argTypes);
+
+ await experimental_requestResponse(
+ channel,
+ SAVE_STORY_REQUEST,
+ SAVE_STORY_RESPONSE,
+ {
+ args: stringifyArgs(requiredArgs),
+ importPath: createNewStoryResult.storyFilePath,
+ csfId: storyId,
+ }
+ );
+ } catch (e) {}
+
+ handleSuccessfullyCreatedStory(componentExportName);
+ handleFileSearch();
+ } catch (e: any) {
+ switch (e?.payload?.type as CreateNewStoryErrorPayload['type']) {
+ case 'STORY_FILE_EXISTS':
+ const err = e as RequestResponseError;
+ await trySelectNewStory(api.selectStory, err.payload.kind);
+ handleStoryAlreadyExists();
+ break;
+ default:
+ setError({ selectedItemId: selectedItemId, error: (e as any)?.message });
+ break;
+ }
+ }
+ },
+ [api?.selectStory, handleSuccessfullyCreatedStory, handleFileSearch, handleStoryAlreadyExists]
+ );
+
+ useEffect(() => {
+ setError(null);
+ }, [fileSearchQueryDeferred]);
+
+ useEffect(() => {
+ return handleFileSearch();
+ }, [handleFileSearch]);
+
+ return (
+
+ );
+};
diff --git a/code/ui/manager/src/components/sidebar/FIleSearchList.utils.tsx b/code/ui/manager/src/components/sidebar/FIleSearchList.utils.tsx
new file mode 100644
index 000000000000..83e2dac00f83
--- /dev/null
+++ b/code/ui/manager/src/components/sidebar/FIleSearchList.utils.tsx
@@ -0,0 +1,118 @@
+import type { Virtualizer } from '@tanstack/react-virtual';
+import { useEffect } from 'react';
+import { flushSync } from 'react-dom';
+
+interface UseArrowKeyNavigationProps {
+ rowVirtualizer: Virtualizer;
+ parentRef: React.MutableRefObject;
+ selectedItem: number | null;
+}
+
+/**
+ * Hook to navigate through the list of items and subitems using the arrow keys
+ */
+export const useArrowKeyNavigation = ({
+ parentRef,
+ rowVirtualizer,
+ selectedItem,
+}: UseArrowKeyNavigationProps) => {
+ useEffect(() => {
+ const handleArrowKeys = (event: KeyboardEvent) => {
+ if (!parentRef.current) {
+ return;
+ }
+
+ const maxIndex = rowVirtualizer.options.count;
+ const activeElement = document.activeElement;
+ const rowIndex = parseInt(activeElement.getAttribute('data-index') || '-1', 10);
+ const isActiveElementInput = activeElement.tagName === 'INPUT';
+
+ const getFirstElement = () =>
+ document.querySelector('[data-index="0"]') as HTMLElement | null;
+ const getLastElement = () =>
+ document.querySelector(`[data-index="${maxIndex - 1}"]`) as HTMLElement | null;
+
+ if (event.code === 'ArrowDown' && activeElement) {
+ event.stopPropagation();
+
+ // If the search input is focused, focus the first element
+ if (isActiveElementInput) {
+ getFirstElement()?.focus();
+ return;
+ }
+
+ // if the last element is focused, focus the first element
+ if (rowIndex === maxIndex - 1) {
+ flushSync(() => {
+ rowVirtualizer.scrollToIndex(0, { align: 'start' });
+ });
+ setTimeout(() => {
+ getFirstElement()?.focus();
+ }, 100);
+ return;
+ }
+
+ // if the focus is on an selected element, focus the first element in the sublist
+ if (selectedItem === rowIndex) {
+ const firstSubListItem = document.querySelector(
+ `[data-index-position="${selectedItem}_first"]`
+ ) as HTMLElement;
+ firstSubListItem?.focus();
+ return;
+ }
+
+ // if the focus is on the last element on a sublist, focus the next parent list element
+ if (selectedItem !== null) {
+ const isLastElementSelected = activeElement
+ .getAttribute('data-index-position')
+ ?.includes('last');
+ if (isLastElementSelected) {
+ const nextElement = document.querySelector(
+ `[data-index="${selectedItem + 1}"]`
+ ) as HTMLElement;
+ nextElement?.focus();
+ return;
+ }
+ }
+
+ const nextElement = activeElement.nextElementSibling as HTMLElement;
+ nextElement?.focus();
+ }
+
+ if (event.code === 'ArrowUp' && activeElement) {
+ if (isActiveElementInput) {
+ flushSync(() => {
+ rowVirtualizer.scrollToIndex(maxIndex - 1, { align: 'start' });
+ });
+ setTimeout(() => {
+ getLastElement()?.focus();
+ }, 100);
+ return;
+ }
+
+ // if the focus is on the first element on a sublist, focus the previous parent list element
+ if (selectedItem !== null) {
+ const isLastElementSelected = activeElement
+ .getAttribute('data-index-position')
+ ?.includes('first');
+ if (isLastElementSelected) {
+ const prevElement = document.querySelector(
+ `[data-index="${selectedItem}"]`
+ ) as HTMLElement;
+ prevElement?.focus();
+ return;
+ }
+ }
+
+ const previousElement = activeElement.previousElementSibling as HTMLElement;
+ previousElement?.focus();
+ }
+ };
+ // listener for arrow keys to select the next/previous element by using the current active element as base
+ document.addEventListener('keydown', handleArrowKeys, { capture: true });
+
+ return () => {
+ document.removeEventListener('keydown', handleArrowKeys, { capture: true });
+ };
+ }, [rowVirtualizer, selectedItem, parentRef]);
+};
diff --git a/code/ui/manager/src/components/sidebar/FileList.tsx b/code/ui/manager/src/components/sidebar/FileList.tsx
new file mode 100644
index 000000000000..420d4ce5dc6e
--- /dev/null
+++ b/code/ui/manager/src/components/sidebar/FileList.tsx
@@ -0,0 +1,215 @@
+import { styled } from '@storybook/theming';
+import { rgba } from 'polished';
+
+export const FileListWrapper = styled('div')(({ theme }) => ({
+ marginTop: '-16px',
+ // after element which fades out the list
+ '&::after': {
+ content: '""',
+ position: 'fixed',
+ pointerEvents: 'none',
+ bottom: 0,
+ left: 0,
+ right: 0,
+ height: '80px',
+ background: `linear-gradient(${rgba(theme.barBg, 0)} 10%, ${theme.barBg} 80%)`,
+ },
+}));
+
+export const FileList = styled('div')(({ theme }) => ({
+ height: '280px',
+ overflow: 'auto',
+ msOverflowStyle: 'none',
+ scrollbarWidth: 'none',
+ position: 'relative',
+ '::-webkit-scrollbar': {
+ display: 'none',
+ },
+}));
+
+export const FileListLi = styled('li')(({ theme }) => ({
+ ':focus-visible': {
+ outline: 'none',
+
+ '.file-list-item': {
+ borderRadius: '4px',
+ background: theme.base === 'dark' ? 'rgba(255,255,255,.1)' : theme.color.mediumlight,
+
+ '> svg': {
+ display: 'flex',
+ },
+ },
+ },
+}));
+
+export const FileListItem = styled('div')(({ theme }) => ({
+ display: 'flex',
+ flexDirection: 'column',
+ position: 'relative',
+}));
+
+export const FileListItemContentWrapper = styled.div<{
+ selected: boolean;
+ disabled: boolean;
+ error: boolean;
+}>(({ theme, selected, disabled, error }) => ({
+ display: 'flex',
+ alignItems: 'flex-start',
+ gap: '8px',
+ alignSelf: 'stretch',
+ padding: '8px 16px',
+ cursor: 'pointer',
+ borderRadius: '4px',
+
+ ...(selected && {
+ borderRadius: '4px',
+ background: theme.base === 'dark' ? 'rgba(255,255,255,.1)' : theme.color.mediumlight,
+
+ '> svg': {
+ display: 'flex',
+ },
+ }),
+
+ ...(disabled && {
+ cursor: 'not-allowed',
+
+ div: {
+ color: `${theme.color.mediumdark} !important`,
+ },
+ }),
+
+ ...(error && {
+ background: theme.base === 'light' ? '#00000011' : '#00000033',
+ }),
+
+ '&:hover': {
+ background: error
+ ? '#00000022'
+ : theme.base === 'dark'
+ ? 'rgba(255,255,255,.1)'
+ : theme.color.mediumlight,
+
+ '> svg': {
+ display: 'flex',
+ },
+ },
+}));
+
+export const FileListUl = styled('ul')({
+ margin: 0,
+ padding: '0 0 0 0',
+ width: '100%',
+ position: 'relative',
+});
+
+export const FileListItemContent = styled('div')({
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'flex-start',
+ width: 'calc(100% - 50px)',
+});
+
+export const FileListIconWrapper = styled('div')<{ error: boolean }>(({ theme, error }) => ({
+ color: error ? theme.color.negativeText : theme.color.secondary,
+}));
+
+export const FileListItemLabel = styled('div')<{ error: boolean }>(({ theme, error }) => ({
+ color: error
+ ? theme.color.negativeText
+ : theme.base === 'dark'
+ ? theme.color.lighter
+ : theme.color.darkest,
+ fontSize: '14px',
+ whiteSpace: 'nowrap',
+ textOverflow: 'ellipsis',
+ overflow: 'hidden',
+ maxWidth: '100%',
+}));
+
+export const FileListItemPath = styled('div')(({ theme }) => ({
+ color: theme.color.mediumdark,
+ fontSize: '14px',
+ whiteSpace: 'nowrap',
+ textOverflow: 'ellipsis',
+ overflow: 'hidden',
+ maxWidth: '100%',
+}));
+
+export const FileListExport = styled('ul')(({ theme }) => ({
+ margin: 0,
+ padding: 0,
+}));
+
+export const FileListItemExport = styled('li')<{ error: boolean }>(({ theme, error }) => ({
+ padding: '8px 16px 8px 16px',
+ marginLeft: '30px',
+ display: 'flex',
+ gap: '8px',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ fontSize: '14px',
+ cursor: 'pointer',
+ borderRadius: '4px',
+
+ ':focus-visible': {
+ outline: 'none',
+ },
+
+ ...(error && {
+ background: '#F9ECEC',
+ color: theme.color.negativeText,
+ }),
+
+ '&:hover,:focus-visible': {
+ background: error
+ ? '#F9ECEC'
+ : theme.base === 'dark'
+ ? 'rgba(255, 255, 255, 0.1)'
+ : theme.color.mediumlight,
+
+ '> svg': {
+ display: 'flex',
+ },
+ },
+
+ '> div > svg': {
+ color: error ? theme.color.negativeText : theme.color.secondary,
+ },
+}));
+
+export const FileListItemExportName = styled('div')(({ theme }) => ({
+ display: 'flex',
+ alignItems: 'center',
+ gap: '8px',
+ width: 'calc(100% - 20px)',
+}));
+
+export const FileListItemExportNameContent = styled('span')(({ theme }) => ({
+ whiteSpace: 'nowrap',
+ textOverflow: 'ellipsis',
+ overflow: 'hidden',
+ maxWidth: 'calc(100% - 160px)',
+ display: 'inline-block',
+}));
+
+export const DefaultExport = styled('span')(({ theme }) => ({
+ display: 'inline-block',
+ padding: `1px ${theme.appBorderRadius}px`,
+ borderRadius: '2px',
+ fontSize: '10px',
+ color: theme.base === 'dark' ? theme.color.lightest : '#727272',
+ backgroundColor: theme.base === 'dark' ? 'rgba(255, 255, 255, 0.1)' : '#F2F4F5',
+}));
+
+export const NoResults = styled('div')(({ theme }) => ({
+ textAlign: 'center',
+ maxWidth: '334px',
+ margin: '16px auto 50px auto',
+ fontSize: '14px',
+ color: theme.base === 'dark' ? theme.color.lightest : '#000',
+}));
+
+export const NoResultsDescription = styled('p')(({ theme }) => ({
+ margin: 0,
+ color: theme.base === 'dark' ? theme.color.defaultText : theme.color.mediumdark,
+}));
diff --git a/code/ui/manager/src/components/sidebar/FileSearchList.stories.tsx b/code/ui/manager/src/components/sidebar/FileSearchList.stories.tsx
new file mode 100644
index 000000000000..f2fb798d5992
--- /dev/null
+++ b/code/ui/manager/src/components/sidebar/FileSearchList.stories.tsx
@@ -0,0 +1,149 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import { fn, fireEvent, findByText, expect } from '@storybook/test';
+
+import { FileSearchList } from './FileSearchList';
+
+const meta = {
+ component: FileSearchList,
+ args: {
+ onNewStory: fn(),
+ },
+ parameters: {
+ layout: 'fullscreen',
+ },
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ isLoading: true,
+ searchResults: null,
+ errorItemId: null,
+ },
+};
+
+export const Empty: Story = {
+ args: {
+ isLoading: false,
+ searchResults: [],
+ errorItemId: null,
+ },
+};
+
+export const WithResults: Story = {
+ play: async ({ canvasElement, args }) => {
+ // use react testing library
+ // select first item in the list and click on it
+ const firstItem = await findByText(canvasElement, 'module-multiple-exports.js');
+ fireEvent.click(firstItem);
+
+ const exportedElement1 = await findByText(canvasElement, 'module-multiple-exports');
+ fireEvent.click(exportedElement1);
+
+ expect(args.onNewStory).toHaveBeenCalledWith({
+ selectedItemId: 'src/module-multiple-exports.js_0',
+ componentExportName: 'default',
+ componentFilePath: 'src/module-multiple-exports.js',
+ componentIsDefaultExport: true,
+ });
+
+ const exportedElement2 = await findByText(canvasElement, 'namedExport');
+ fireEvent.click(exportedElement2);
+
+ expect(args.onNewStory).toHaveBeenCalledWith({
+ selectedItemId: 'src/module-multiple-exports.js_1',
+ componentExportName: 'namedExport',
+ componentFilePath: 'src/module-multiple-exports.js',
+ componentIsDefaultExport: false,
+ });
+
+ const singleExport = await findByText(canvasElement, 'module-single-export.js');
+ fireEvent.click(singleExport);
+
+ expect(args.onNewStory).toHaveBeenCalledWith({
+ selectedItemId: 'src/module-single-export.js',
+ componentExportName: 'default',
+ componentFilePath: 'src/module-single-export.js',
+ componentIsDefaultExport: true,
+ });
+
+ expect(args.onNewStory).toHaveBeenCalledTimes(3);
+
+ const noExportsModule1 = await findByText(canvasElement, 'no-exports-module.js');
+ fireEvent.click(noExportsModule1);
+
+ expect(args.onNewStory).toHaveBeenCalledTimes(3);
+
+ const noExportsModule2 = await findByText(canvasElement, 'no-exports-module-1.js');
+ fireEvent.click(noExportsModule2);
+
+ expect(args.onNewStory).toHaveBeenCalledTimes(3);
+ },
+ args: {
+ isLoading: false,
+ errorItemId: null,
+ searchResults: [
+ {
+ exportedComponents: [],
+ storyFileExists: false,
+ filepath: 'src/no-exports-module.js',
+ },
+ {
+ storyFileExists: false,
+ exportedComponents: [
+ {
+ default: true,
+ name: 'default',
+ },
+ {
+ default: false,
+ name: 'namedExport',
+ },
+ ],
+ filepath: 'src/module-multiple-exports.js',
+ },
+ {
+ storyFileExists: false,
+ exportedComponents: null,
+ filepath: 'src/no-exports-module-1.js',
+ },
+ {
+ storyFileExists: false,
+ exportedComponents: [
+ {
+ default: true,
+ name: 'default',
+ },
+ ],
+ filepath: 'src/module-single-export.js',
+ },
+ {
+ storyFileExists: true,
+ exportedComponents: [
+ {
+ default: true,
+ name: 'default',
+ },
+ {
+ default: false,
+ name: 'namedExportWithStory',
+ },
+ ],
+ filepath: 'src/has-story-file-with-multiple-exports.js',
+ },
+ {
+ storyFileExists: true,
+ exportedComponents: [
+ {
+ default: true,
+ name: 'default',
+ },
+ ],
+ filepath: 'src/has-story-file.js',
+ },
+ ],
+ },
+};
diff --git a/code/ui/manager/src/components/sidebar/FileSearchList.tsx b/code/ui/manager/src/components/sidebar/FileSearchList.tsx
new file mode 100644
index 000000000000..316704abfba6
--- /dev/null
+++ b/code/ui/manager/src/components/sidebar/FileSearchList.tsx
@@ -0,0 +1,368 @@
+import React, { memo, useCallback, useMemo, useState } from 'react';
+import { ChevronDownIcon, ChevronRightIcon, ComponentIcon } from '@storybook/icons';
+import { styled } from '@storybook/theming';
+import { FileSearchListLoadingSkeleton } from './FileSearchListSkeleton';
+import {
+ DefaultExport,
+ FileList,
+ FileListExport,
+ FileListIconWrapper,
+ FileListItem,
+ FileListItemContent,
+ FileListItemContentWrapper,
+ FileListItemExport,
+ FileListItemExportName,
+ FileListItemExportNameContent,
+ FileListItemLabel,
+ FileListItemPath,
+ FileListLi,
+ FileListUl,
+ FileListWrapper,
+ NoResults,
+ NoResultsDescription,
+} from './FileList';
+import type { VirtualItem } from '@tanstack/react-virtual';
+import { useVirtualizer } from '@tanstack/react-virtual';
+import type {
+ CreateNewStoryRequestPayload,
+ FileComponentSearchResponsePayload,
+} from '@storybook/core-events';
+import { WithTooltip, TooltipNote } from '@storybook/components';
+import { useArrowKeyNavigation } from './FIleSearchList.utils';
+
+export type SearchResult = FileComponentSearchResponsePayload['files'][0];
+
+export interface NewStoryPayload extends CreateNewStoryRequestPayload {
+ selectedItemId: string | number;
+}
+
+const ChevronRightIconStyled = styled(ChevronRightIcon)(({ theme }) => ({
+ display: 'none',
+ alignSelf: 'center',
+ color: theme.color.mediumdark,
+}));
+
+const ChevronDownIconStyled = styled(ChevronDownIcon)(({ theme }) => ({
+ display: 'none',
+ alignSelf: 'center',
+ color: theme.color.mediumdark,
+}));
+
+interface FileSearchListProps {
+ isLoading: boolean;
+ searchResults: Array | null;
+ onNewStory: (props: NewStoryPayload) => void;
+ errorItemId?: number | string;
+}
+
+interface FileItemContentProps {
+ virtualItem: VirtualItem;
+ selected: number | null;
+ searchResult: SearchResult;
+}
+
+interface FileItemSelectionPayload {
+ virtualItem: VirtualItem;
+ searchResult: SearchResult;
+ itemId: string;
+}
+
+interface FileItemComponentSelectionPayload {
+ searchResult: SearchResult;
+ component: SearchResult['exportedComponents'][0];
+ id: string;
+}
+
+export const FileSearchList = memo(function FileSearchList({
+ isLoading,
+ searchResults,
+ onNewStory,
+ errorItemId,
+}: FileSearchListProps) {
+ const [selectedItem, setSelectedItem] = useState(null);
+ const parentRef = React.useRef();
+
+ const sortedSearchResults = useMemo(() => {
+ // search results with no exports should be at the end of the list
+ return [...(searchResults ?? [])]?.sort((a, b) => {
+ const isALowPriority = a.exportedComponents === null || a.exportedComponents?.length === 0;
+ const hasAStory = a.storyFileExists;
+
+ const isBLowPriority = b.exportedComponents === null || b.exportedComponents?.length === 0;
+ const hasBStory = b.storyFileExists;
+
+ if (hasAStory && !hasBStory) {
+ return -1;
+ }
+
+ if (hasBStory && !hasAStory) {
+ return 1;
+ }
+
+ if (isALowPriority && !isBLowPriority) {
+ return 1;
+ }
+
+ if (!isALowPriority && isBLowPriority) {
+ return -1;
+ }
+
+ return 0;
+ });
+ }, [searchResults]);
+
+ const count = searchResults?.length || 0;
+
+ const rowVirtualizer = useVirtualizer({
+ count,
+ getScrollElement: () => parentRef.current,
+ paddingStart: 16,
+ paddingEnd: 40,
+ estimateSize: () => 54,
+ overscan: 2,
+ });
+
+ useArrowKeyNavigation({ rowVirtualizer, parentRef, selectedItem });
+
+ const handleFileItemSelection = useCallback(
+ ({ virtualItem, searchResult, itemId }: FileItemSelectionPayload) => {
+ if (searchResult?.exportedComponents?.length > 1) {
+ setSelectedItem((sItem) => {
+ if (sItem === virtualItem.index) {
+ return null;
+ }
+
+ return virtualItem.index;
+ });
+ } else if (searchResult?.exportedComponents?.length === 1) {
+ onNewStory({
+ componentExportName: searchResult.exportedComponents[0].name,
+ componentFilePath: searchResult.filepath,
+ componentIsDefaultExport: searchResult.exportedComponents[0].default,
+ selectedItemId: itemId,
+ componentExportCount: 1,
+ });
+ }
+ },
+ [onNewStory]
+ );
+
+ const handleFileItemComponentSelection = useCallback(
+ ({ searchResult, component, id }: FileItemComponentSelectionPayload) => {
+ onNewStory({
+ componentExportName: component.name,
+ componentFilePath: searchResult.filepath,
+ componentIsDefaultExport: component.default,
+ selectedItemId: id,
+ componentExportCount: searchResult.exportedComponents.length,
+ });
+ },
+ [onNewStory]
+ );
+
+ const ListItem = useCallback(
+ ({ virtualItem, selected, searchResult }: FileItemContentProps) => {
+ const itemError = errorItemId === searchResult.filepath;
+ const itemSelected = selected === virtualItem.index;
+
+ return (
+
+
+
+
+
+
+
+ {searchResult.filepath.split('/').at(-1)}
+
+ {searchResult.filepath}
+
+ {itemSelected ? : }
+
+ {searchResult?.exportedComponents?.length > 1 && itemSelected && (
+ {
+ e.stopPropagation();
+ }}
+ onKeyUp={(e) => {
+ if (e.key === 'Enter') {
+ e.stopPropagation();
+ }
+ }}
+ >
+ {searchResult.exportedComponents?.map((component, itemExportId) => {
+ const itemExportError = errorItemId === `${searchResult.filepath}_${itemExportId}`;
+ const position =
+ itemExportId === 0
+ ? 'first'
+ : itemExportId === searchResult.exportedComponents.length - 1
+ ? 'last'
+ : 'middle';
+
+ return (
+ {
+ handleFileItemComponentSelection({
+ searchResult,
+ component,
+ id: `${searchResult.filepath}_${itemExportId}`,
+ });
+ }}
+ onKeyUp={(event) => {
+ if (event.key === 'Enter') {
+ handleFileItemComponentSelection({
+ searchResult,
+ component,
+ id: `${searchResult.filepath}_${itemExportId}`,
+ });
+ }
+ }}
+ >
+
+
+ {component.default ? (
+ <>
+
+ {searchResult.filepath.split('/').at(-1)?.split('.')?.at(0)}
+
+ Default export
+ >
+ ) : (
+ component.name
+ )}
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+ },
+ [handleFileItemComponentSelection, errorItemId]
+ );
+
+ if (isLoading && (searchResults === null || searchResults?.length === 0)) {
+ return ;
+ }
+
+ if (searchResults?.length === 0) {
+ return (
+
+ We could not find any file with that name
+
+ You may want to try using different keywords, check for typos, and adjust your filters
+
+
+ );
+ }
+
+ if (sortedSearchResults?.length > 0) {
+ return (
+
+
+
+ {rowVirtualizer.getVirtualItems().map((virtualItem) => {
+ const searchResult = sortedSearchResults[virtualItem.index];
+ const noExports =
+ searchResult.exportedComponents === null ||
+ searchResult.exportedComponents?.length === 0;
+
+ const itemProps = {};
+
+ return (
+ {
+ handleFileItemSelection({
+ virtualItem,
+ itemId: searchResult.filepath,
+ searchResult,
+ });
+ }}
+ onKeyUp={(event) => {
+ if (event.key === 'Enter') {
+ handleFileItemSelection({
+ virtualItem,
+ itemId: searchResult.filepath,
+ searchResult,
+ });
+ }
+ }}
+ style={{
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ width: '100%',
+ transform: `translateY(${virtualItem.start}px)`,
+ }}
+ tabIndex={0}
+ >
+ {noExports ? (
+
+ }
+ >
+
+
+ ) : (
+
+ )}
+
+ );
+ })}
+
+
+
+ );
+ }
+
+ return null;
+});
diff --git a/code/ui/manager/src/components/sidebar/FileSearchListSkeleton.stories.tsx b/code/ui/manager/src/components/sidebar/FileSearchListSkeleton.stories.tsx
new file mode 100644
index 000000000000..eaeaca9c0f75
--- /dev/null
+++ b/code/ui/manager/src/components/sidebar/FileSearchListSkeleton.stories.tsx
@@ -0,0 +1,15 @@
+import type { Meta, StoryObj } from '@storybook/react';
+
+import { FileSearchListLoadingSkeleton } from './FileSearchListSkeleton';
+
+const meta = {
+ component: FileSearchListLoadingSkeleton,
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {},
+};
diff --git a/code/ui/manager/src/components/sidebar/FileSearchListSkeleton.tsx b/code/ui/manager/src/components/sidebar/FileSearchListSkeleton.tsx
new file mode 100644
index 000000000000..f8050f4942f6
--- /dev/null
+++ b/code/ui/manager/src/components/sidebar/FileSearchListSkeleton.tsx
@@ -0,0 +1,59 @@
+import React from 'react';
+import { styled } from '@storybook/theming';
+import { FileList, FileListItem } from './FileList';
+
+const FileListItemContentWrapperSkeleton = styled('div')(({ theme }) => ({
+ display: 'flex',
+ alignItems: 'flex-start',
+ gap: '8px',
+ alignSelf: 'stretch',
+ padding: '8px 16px',
+}));
+
+const FileListItemContentSkeleton = styled('div')({
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'flex-start',
+ width: '100%',
+ borderRadius: '3px',
+});
+
+const FileListIconWrapperSkeleton = styled.div(({ theme }) => ({
+ width: '14px',
+ height: '14px',
+ borderRadius: '3px',
+ marginTop: '1px',
+ background: theme.base === 'dark' ? 'rgba(255,255,255,.1)' : 'rgba(0,0,0,.1)',
+ animation: `${theme.animation.glow} 1.5s ease-in-out infinite`,
+}));
+
+const FileListItemSkeleton = styled.div(({ theme }) => ({
+ height: '16px',
+ borderRadius: '3px',
+ background: theme.base === 'dark' ? 'rgba(255,255,255,.1)' : 'rgba(0,0,0,.1)',
+ animation: `${theme.animation.glow} 1.5s ease-in-out infinite`,
+ width: '100%',
+ maxWidth: '100%',
+
+ '+ div': {
+ marginTop: '6px',
+ },
+}));
+
+export const FileSearchListLoadingSkeleton = () => {
+ return (
+
+ {[1, 2, 3].map((result) => (
+
+
+
+
+
+
+
+
+
+ ))}
+
+ );
+};
diff --git a/code/ui/manager/src/components/sidebar/FileSearchModal.stories.tsx b/code/ui/manager/src/components/sidebar/FileSearchModal.stories.tsx
new file mode 100644
index 000000000000..aaa604953aea
--- /dev/null
+++ b/code/ui/manager/src/components/sidebar/FileSearchModal.stories.tsx
@@ -0,0 +1,134 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import { findByText, fireEvent, fn, expect } from '@storybook/test';
+import { WithResults } from './FileSearchList.stories';
+import React, { useState } from 'react';
+
+import { FileSearchModal } from './FileSearchModal';
+
+const meta = {
+ component: FileSearchModal,
+ args: {
+ open: true,
+ setError: fn(),
+ onCreateNewStory: fn(),
+ onOpenChange: fn(),
+ setFileSearchQuery: fn(),
+ },
+ // This decorator is used to show the modal in the side by side view
+ decorators: [
+ (Story, context) => {
+ const [container, setContainer] = useState(null);
+
+ if (context.globals.theme === 'side-by-side') {
+ return (
+ {
+ setContainer(element);
+ }}
+ style={{
+ width: '100%',
+ height: '100%',
+ minHeight: '600px',
+ transform: 'translateZ(0)',
+ }}
+ >
+ {Story({ args: { ...context.args, container } })}
+
+ );
+ }
+
+ return Story();
+ },
+ ],
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const InitialState: Story = {
+ args: {
+ fileSearchQuery: '',
+ fileSearchQueryDeferred: '',
+ isLoading: false,
+ error: null,
+ searchResults: null,
+ },
+};
+
+export const Loading: Story = {
+ args: {
+ fileSearchQuery: 'src',
+ fileSearchQueryDeferred: 'src',
+ isLoading: true,
+ error: null,
+ searchResults: null,
+ },
+};
+
+export const LoadingWithPreviousResults: Story = {
+ args: {
+ fileSearchQuery: 'src',
+ fileSearchQueryDeferred: 'src',
+ isLoading: true,
+ error: null,
+ searchResults: WithResults.args.searchResults,
+ },
+};
+
+export const Empty: Story = {
+ args: {
+ fileSearchQuery: 'src',
+ fileSearchQueryDeferred: 'src',
+ isLoading: false,
+ error: null,
+ searchResults: [],
+ },
+};
+
+export const WithSearchResults: Story = {
+ args: {
+ fileSearchQuery: 'src',
+ fileSearchQueryDeferred: 'src',
+ isLoading: false,
+ error: null,
+ searchResults: WithResults.args.searchResults,
+ },
+ play: async ({ canvasElement, args }) => {
+ const parent = canvasElement.parentNode as HTMLElement;
+
+ const moduleSingleExport = await findByText(parent, 'module-single-export.js');
+ await fireEvent.click(moduleSingleExport);
+
+ expect(args.onCreateNewStory).toHaveBeenCalledWith({
+ componentExportCount: 1,
+ componentExportName: 'default',
+ componentFilePath: 'src/module-single-export.js',
+ componentIsDefaultExport: true,
+ selectedItemId: 'src/module-single-export.js',
+ });
+ },
+};
+
+export const WithSearchResultsAndError: Story = {
+ args: {
+ fileSearchQuery: 'src',
+ fileSearchQueryDeferred: 'src',
+ isLoading: false,
+ error: { error: 'Some error occured', selectedItemId: 'src/module-multiple-exports.js' },
+ searchResults: WithResults.args.searchResults,
+ },
+};
+
+export const WithSearchResultsAndMultiLineError: Story = {
+ args: {
+ fileSearchQuery: 'src',
+ fileSearchQueryDeferred: 'src',
+ isLoading: false,
+ error: {
+ error: 'A very long error occured. A very long error occured. A very long error occured.',
+ selectedItemId: 'src/module-multiple-exports.js',
+ },
+ searchResults: WithResults.args.searchResults,
+ },
+};
diff --git a/code/ui/manager/src/components/sidebar/FileSearchModal.tsx b/code/ui/manager/src/components/sidebar/FileSearchModal.tsx
new file mode 100644
index 000000000000..40f7b4e76ba9
--- /dev/null
+++ b/code/ui/manager/src/components/sidebar/FileSearchModal.tsx
@@ -0,0 +1,219 @@
+import React, { useEffect, useState, useTransition } from 'react';
+import { Modal, Form } from '@storybook/components';
+import { styled } from '@storybook/theming';
+import { CloseAltIcon, SearchIcon, SyncIcon } from '@storybook/icons';
+
+import type { NewStoryPayload, SearchResult } from './FileSearchList';
+import { FileSearchList } from './FileSearchList';
+import { useMeasure } from '../../hooks/useMeasure';
+
+const MODAL_HEIGHT = 418;
+
+const ModalStyled = styled(Modal)(() => ({
+ boxShadow: 'none',
+ background: 'transparent',
+}));
+
+const ModalChild = styled.div<{ height?: number }>(({ theme, height }) => ({
+ backgroundColor: theme.background.bar,
+ borderRadius: 6,
+ boxShadow: `rgba(255, 255, 255, 0.05) 0 0 0 1px inset, rgba(14, 18, 22, 0.35) 0px 10px 18px -10px`,
+ padding: '16px',
+ transition: 'height 0.3s',
+ height: height ? `${height + 32}px` : 'auto',
+ overflow: 'hidden',
+}));
+
+const ModalContent = styled(Modal.Content)(({ theme }) => ({
+ margin: 0,
+ color: theme.base === 'dark' ? theme.color.lighter : theme.color.mediumdark,
+}));
+
+const ModalInput = styled(Form.Input)(({ theme }) => ({
+ paddingLeft: 40,
+ paddingRight: 28,
+ fontSize: 14,
+ height: 40,
+
+ ...(theme.base === 'light' && {
+ color: theme.color.darkest,
+ }),
+
+ '::placeholder': {
+ color: theme.color.mediumdark,
+ },
+ '&:invalid:not(:placeholder-shown)': {
+ boxShadow: `${theme.color.negative} 0 0 0 1px inset`,
+ },
+ '&::-webkit-search-decoration, &::-webkit-search-cancel-button, &::-webkit-search-results-button, &::-webkit-search-results-decoration':
+ {
+ display: 'none',
+ },
+}));
+
+const SearchField = styled.div({
+ display: 'flex',
+ flexDirection: 'column',
+ flexGrow: 1,
+ position: 'relative',
+});
+
+const SearchIconWrapper = styled.div(({ theme }) => ({
+ position: 'absolute',
+ top: 0,
+ left: 16,
+ zIndex: 1,
+ pointerEvents: 'none',
+ color: theme.darkest,
+ display: 'flex',
+ alignItems: 'center',
+ height: '100%',
+}));
+
+const LoadingIcon = styled.div(({ theme }) => ({
+ position: 'absolute',
+ top: 0,
+ right: 16,
+ zIndex: 1,
+ color: theme.darkest,
+ display: 'flex',
+ alignItems: 'center',
+ height: '100%',
+ '@keyframes spin': {
+ from: { transform: 'rotate(0deg)' },
+ to: { transform: 'rotate(360deg)' },
+ },
+ animation: 'spin 1s linear infinite',
+}));
+
+const ModalError = styled(Modal.Error)({
+ position: 'absolute',
+ padding: '8px 40px 8px 16px',
+ bottom: 0,
+ maxHeight: 'initial',
+ width: '100%',
+
+ div: {
+ wordBreak: 'break-word',
+ },
+
+ '> div': {
+ padding: 0,
+ },
+});
+
+const ModalErrorCloseIcon = styled(CloseAltIcon)({
+ position: 'absolute',
+ top: 4,
+ right: -24,
+ cursor: 'pointer',
+});
+
+type Error = { selectedItemId?: number | string; error: string } | null;
+
+interface FileSearchModalProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ fileSearchQuery: string;
+ fileSearchQueryDeferred: string;
+ setFileSearchQuery: (query: string) => void;
+ isLoading: boolean;
+ error: Error;
+ searchResults: SearchResult[] | null;
+ onCreateNewStory: (payload: NewStoryPayload) => void;
+ setError: (error: Error) => void;
+ container?: HTMLElement;
+}
+
+export const FileSearchModal = ({
+ open,
+ onOpenChange,
+ fileSearchQuery,
+ setFileSearchQuery,
+ isLoading,
+ error,
+ searchResults,
+ onCreateNewStory,
+ setError,
+ container,
+}: FileSearchModalProps) => {
+ const [modalContentRef, modalContentDimensions] = useMeasure();
+ const [modalMaxHeight, setModalMaxHeight] = useState(modalContentDimensions.height);
+ const [, startTransition] = useTransition();
+ // This internal state is used to maintain cursor position when the user types in the search input
+ // See: https://github.com/facebook/react/issues/5386#issuecomment-334001976
+ const [searchInputValue, setSearchInputValue] = useState(fileSearchQuery);
+
+ useEffect(() => {
+ if (modalMaxHeight < modalContentDimensions.height) {
+ setModalMaxHeight(modalContentDimensions.height);
+ }
+ }, [modalContentDimensions.height, modalMaxHeight]);
+
+ return (
+ {
+ onOpenChange(false);
+ }}
+ onInteractOutside={() => {
+ onOpenChange(false);
+ }}
+ container={container}
+ >
+
+
+
+ Add a new story
+ We will create a new story for your component
+
+
+
+
+
+ {
+ const newValue = (e.target as HTMLInputElement).value;
+ setSearchInputValue(newValue);
+ startTransition(() => {
+ setFileSearchQuery(newValue);
+ });
+ }}
+ />
+ {isLoading && (
+
+
+
+ )}
+
+ {
+
+ }
+
+
+ {error && fileSearchQuery !== '' && (
+
+ {error.error}
+ {
+ setError(null);
+ }}
+ />
+
+ )}
+
+ );
+};
diff --git a/code/ui/manager/src/components/sidebar/FileSearchModal.utils.test.tsx b/code/ui/manager/src/components/sidebar/FileSearchModal.utils.test.tsx
new file mode 100644
index 000000000000..d86a9c208d68
--- /dev/null
+++ b/code/ui/manager/src/components/sidebar/FileSearchModal.utils.test.tsx
@@ -0,0 +1,126 @@
+import { describe, expect, it } from 'vitest';
+import { extractSeededRequiredArgs } from './FileSearchModal.utils';
+import type { ArgTypes } from '@storybook/csf';
+
+describe('FileSearchModal.utils', () => {
+ describe('extractSeededRequiredArgs', () => {
+ it('should extract seeded required args', () => {
+ const argTypes = {
+ string: {
+ type: { name: 'string', required: true },
+ },
+ stringOptional: {
+ type: { name: 'string', required: false },
+ },
+ number: {
+ type: { name: 'number', required: true },
+ },
+ boolean: {
+ type: { name: 'boolean', required: true },
+ },
+ function: {
+ type: { name: 'function', required: true },
+ },
+ object: {
+ type: {
+ name: 'object',
+ required: true,
+ value: {
+ a: { name: 'string', required: true },
+ b: { name: 'number' },
+ },
+ },
+ },
+ union: {
+ type: {
+ name: 'union',
+ required: true,
+ value: [{ name: 'string', required: true }, { name: 'number' }],
+ },
+ },
+ enum: {
+ type: {
+ name: 'enum',
+ required: true,
+ value: ['a', 'b', 'c'],
+ },
+ options: ['a', 'b', 'c'],
+ },
+ otherObject: {
+ type: { name: 'other', required: true, value: '' },
+ control: { type: 'object' },
+ },
+ otherInlineRadio: {
+ type: { name: 'other', required: true, value: '' },
+ control: { type: 'inline-radio', options: ['a', 'b', 'c'] },
+ },
+ otherRadio: {
+ type: { name: 'other', required: true, value: '' },
+ control: { type: 'radio', options: ['d', 'e', 'f'] },
+ },
+ otherInlineCheck: {
+ type: { name: 'other', required: true, value: '' },
+ control: { type: 'inline-check', options: ['g', 'h', 'i'] },
+ },
+ otherCheck: {
+ type: { name: 'other', required: true, value: '' },
+ control: { type: 'check', options: ['j', 'k', 'l'] },
+ },
+ otherSelect: {
+ type: { name: 'other', required: true, value: '' },
+ control: { type: 'select', options: ['m', 'n', 'o'] },
+ },
+ otherMultiSelect: {
+ type: { name: 'other', required: true, value: '' },
+ control: { type: 'multi-select', options: ['p', 'q', 'r'] },
+ },
+ otherColor: {
+ type: { name: 'other', required: true, value: '' },
+ control: { type: 'color' },
+ },
+ intersection: {
+ type: {
+ name: 'intersection',
+ required: true,
+ value: [
+ {
+ name: 'object',
+ value: {
+ a: { name: 'string', required: true },
+ b: { name: 'number' },
+ },
+ },
+ ],
+ },
+ },
+ tuple: {
+ type: {
+ name: 'other',
+ required: true,
+ value: 'tuple',
+ },
+ },
+ } as ArgTypes;
+
+ expect(extractSeededRequiredArgs(argTypes)).toEqual({
+ boolean: true,
+ function: expect.any(Function),
+ number: 0,
+ enum: 'a',
+ otherCheck: 'j',
+ otherColor: '#000000',
+ otherInlineCheck: 'g',
+ otherInlineRadio: 'a',
+ otherMultiSelect: 'p',
+ otherObject: {},
+ otherRadio: 'd',
+ otherSelect: 'm',
+ string: 'string',
+ object: { a: 'a' },
+ union: 'union',
+ intersection: { a: 'a' },
+ tuple: [],
+ });
+ });
+ });
+});
diff --git a/code/ui/manager/src/components/sidebar/FileSearchModal.utils.tsx b/code/ui/manager/src/components/sidebar/FileSearchModal.utils.tsx
new file mode 100644
index 000000000000..f334aadc461c
--- /dev/null
+++ b/code/ui/manager/src/components/sidebar/FileSearchModal.utils.tsx
@@ -0,0 +1,117 @@
+import type { ArgTypes, SBType } from '@storybook/csf';
+
+export function extractSeededRequiredArgs(argTypes: ArgTypes) {
+ const extractedArgTypes = Object.keys(argTypes).reduce(
+ (acc, key: keyof typeof argTypes) => {
+ const argType = argTypes[key];
+
+ if (typeof argType.control === 'object' && 'type' in argType.control) {
+ switch (argType.control.type) {
+ case 'object':
+ acc[key] = {};
+ break;
+ case 'inline-radio':
+ case 'radio':
+ case 'inline-check':
+ case 'check':
+ case 'select':
+ case 'multi-select':
+ acc[key] = argType.control.options?.[0];
+ break;
+ case 'color':
+ acc[key] = '#000000';
+ break;
+ default:
+ break;
+ }
+ }
+
+ setArgType(argType.type, acc, key);
+ return acc;
+ },
+ {} as Record
+ );
+ return extractedArgTypes;
+}
+
+function setArgType(
+ type: 'string' | 'number' | 'boolean' | 'symbol' | 'function' | SBType,
+ obj: Record,
+ objKey: string | number
+) {
+ if (typeof type === 'string' || !type.required) {
+ return;
+ }
+
+ switch (type.name) {
+ case 'boolean':
+ obj[objKey] = true;
+ break;
+ case 'number':
+ obj[objKey] = 0;
+ break;
+ case 'string':
+ obj[objKey] = objKey;
+ break;
+ case 'array':
+ obj[objKey] = [];
+ break;
+ case 'object':
+ obj[objKey] = {};
+ Object.entries(type.value ?? {}).forEach(([typeKey, typeVal]) => {
+ setArgType(typeVal, obj[objKey], typeKey);
+ });
+ break;
+ case 'function':
+ obj[objKey] = () => {};
+ break;
+ case 'intersection':
+ if (type.value?.every((v) => v.name === 'object')) {
+ obj[objKey] = {};
+ type.value?.forEach((typeVal) => {
+ if (typeVal.name === 'object') {
+ Object.entries(typeVal.value ?? {}).forEach(([typeValKey, typeValVal]) => {
+ setArgType(typeValVal, obj[objKey], typeValKey);
+ });
+ }
+ });
+ }
+ break;
+ case 'union':
+ if (type.value?.[0] !== undefined) {
+ setArgType(type.value[0], obj, objKey);
+ }
+ break;
+
+ case 'enum':
+ if (type.value?.[0] !== undefined) {
+ obj[objKey] = type.value?.[0];
+ }
+ break;
+
+ case 'other':
+ if (typeof type.value === 'string' && type.value === 'tuple') {
+ obj[objKey] = [];
+ }
+ break;
+ default:
+ break;
+ }
+}
+
+export async function trySelectNewStory(
+ selectStory: (id: string) => Promise | void,
+ storyId: string,
+ attempt = 1
+): Promise {
+ if (attempt > 10) {
+ throw new Error('We could not select the new story. Please try again.');
+ }
+
+ try {
+ await selectStory(storyId);
+ } catch (e) {
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ return trySelectNewStory(selectStory, storyId, attempt + 1);
+ }
+}
diff --git a/code/ui/manager/src/components/sidebar/IconSymbols.stories.tsx b/code/ui/manager/src/components/sidebar/IconSymbols.stories.tsx
new file mode 100644
index 000000000000..76136230d3d8
--- /dev/null
+++ b/code/ui/manager/src/components/sidebar/IconSymbols.stories.tsx
@@ -0,0 +1,13 @@
+import type { Meta, StoryObj } from '@storybook/react';
+
+import { IconSymbols } from './IconSymbols';
+
+const meta = {
+ component: IconSymbols,
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
diff --git a/code/ui/manager/src/components/sidebar/Search.stories.tsx b/code/ui/manager/src/components/sidebar/Search.stories.tsx
index 8eb50c9832c9..8b796158db8d 100644
--- a/code/ui/manager/src/components/sidebar/Search.stories.tsx
+++ b/code/ui/manager/src/components/sidebar/Search.stories.tsx
@@ -43,6 +43,12 @@ const baseProps = {
export const Simple: StoryFn = () => {() => null} ;
+export const SimpleWithCreateButton: StoryFn = () => (
+
+ {() => null}
+
+);
+
export const FilledIn: StoryFn = () => (
{() => }
diff --git a/code/ui/manager/src/components/sidebar/Search.tsx b/code/ui/manager/src/components/sidebar/Search.tsx
index 0df164a2d35b..6cfa55ec415e 100644
--- a/code/ui/manager/src/components/sidebar/Search.tsx
+++ b/code/ui/manager/src/components/sidebar/Search.tsx
@@ -6,7 +6,8 @@ import type { FuseOptions } from 'fuse.js';
import Fuse from 'fuse.js';
import { global } from '@storybook/global';
import React, { useRef, useState, useCallback } from 'react';
-import { CloseIcon, SearchIcon } from '@storybook/icons';
+import { CloseIcon, PlusIcon, SearchIcon } from '@storybook/icons';
+import { IconButton, TooltipNote, WithTooltip } from '@storybook/components';
import { DEFAULT_REF_ID } from './Sidebar';
import type {
CombinedDataset,
@@ -21,6 +22,7 @@ import { isSearchResult, isExpandType } from './types';
import { scrollIntoView, searchItem } from '../../utils/tree';
import { getGroupStatus, getHighestStatus } from '../../utils/status';
import { useLayout } from '../layout/LayoutProvider';
+import { CreateNewStoryFileModal } from './CreateNewStoryFileModal';
const { document } = global;
@@ -43,6 +45,16 @@ const options = {
],
} as FuseOptions;
+const SearchBar = styled.div({
+ display: 'flex',
+ flexDirection: 'row',
+ columnGap: 6,
+});
+
+const TooltipNoteWrapper = styled(TooltipNote)({
+ margin: 0,
+});
+
const ScreenReaderLabel = styled.label({
position: 'absolute',
left: -10000,
@@ -52,6 +64,10 @@ const ScreenReaderLabel = styled.label({
overflow: 'hidden',
});
+const CreateNewStoryButton = styled(IconButton)(({ theme }) => ({
+ color: theme.color.mediumdark,
+}));
+
const SearchIconWrapper = styled.div(({ theme }) => ({
position: 'absolute',
top: 0,
@@ -67,15 +83,17 @@ const SearchIconWrapper = styled.div(({ theme }) => ({
const SearchField = styled.div({
display: 'flex',
flexDirection: 'column',
+ flexGrow: 1,
position: 'relative',
});
const Input = styled.input(({ theme }) => ({
appearance: 'none',
- height: 32,
+ height: 28,
paddingLeft: 28,
paddingRight: 28,
- border: `1px solid ${theme.appBorderColor}`,
+ border: 0,
+ boxShadow: `${theme.button.border} 0 0 0 1px inset`,
background: 'transparent',
borderRadius: 4,
fontSize: `${theme.typography.size.s1 + 1}px`,
@@ -113,7 +131,7 @@ const Input = styled.input(({ theme }) => ({
const FocusKey = styled.code(({ theme }) => ({
position: 'absolute',
- top: 8,
+ top: 6,
right: 9,
height: 16,
zIndex: 1,
@@ -146,35 +164,30 @@ const ClearIcon = styled.div(({ theme }) => ({
const FocusContainer = styled.div({ outline: 0 });
+const isDevelopment = global.CONFIG_TYPE === 'DEVELOPMENT';
+const isRendererReact = global.STORYBOOK_RENDERER === 'react';
+
export const Search = React.memo<{
children: SearchChildrenFn;
dataset: CombinedDataset;
enableShortcuts?: boolean;
getLastViewed: () => Selection[];
initialQuery?: string;
+ showCreateStoryButton?: boolean;
}>(function Search({
children,
dataset,
enableShortcuts = true,
getLastViewed,
initialQuery = '',
+ showCreateStoryButton = isDevelopment && isRendererReact,
}) {
const api = useStorybookApi();
const inputRef = useRef(null);
const [inputPlaceholder, setPlaceholder] = useState('Find components');
const [allComponents, showAllComponents] = useState(false);
const searchShortcut = api ? shortcutToHumanString(api.getShortcutKeys().search) : '/';
-
- const selectStory = useCallback(
- (id: string, refId: string) => {
- if (api) {
- api.selectStory(id, undefined, { ref: refId !== DEFAULT_REF_ID && refId });
- }
- inputRef.current.blur();
- showAllComponents(false);
- },
- [api, inputRef, showAllComponents, DEFAULT_REF_ID]
- );
+ const [isFileSearchModalOpen, setIsFileSearchModalOpen] = useState(false);
const makeFuse = useCallback(() => {
const list = dataset.entries.reduce((acc, [refId, { index, status }]) => {
@@ -232,6 +245,26 @@ export const Search = React.memo<{
[allComponents, makeFuse]
);
+ const onSelect = useCallback(
+ (selectedItem: DownshiftItem) => {
+ if (isSearchResult(selectedItem)) {
+ const { id, refId } = selectedItem.item;
+ api?.selectStory(id, undefined, { ref: refId !== DEFAULT_REF_ID && refId });
+ inputRef.current.blur();
+ showAllComponents(false);
+ return;
+ }
+ if (isExpandType(selectedItem)) {
+ selectedItem.showAll();
+ }
+ },
+ [api]
+ );
+
+ const onInputValueChange = useCallback((inputValue: string, stateAndHelpers: any) => {
+ showAllComponents(false);
+ }, []);
+
const stateReducer = useCallback(
(state: DownshiftState, changes: StateChangeOptions) => {
switch (changes.type) {
@@ -242,13 +275,12 @@ export const Search = React.memo<{
inputValue: state.inputValue,
// Return to the tree view after selecting an item
isOpen: state.inputValue && !state.selectedItem,
- selectedItem: null,
};
}
case Downshift.stateChangeTypes.mouseUp: {
// Prevent clearing the input on refocus
- return {};
+ return state;
}
case Downshift.stateChangeTypes.keyDownEscape: {
@@ -256,38 +288,29 @@ export const Search = React.memo<{
// Clear the inputValue, but don't return to the tree view
return { ...changes, inputValue: '', isOpen: true, selectedItem: null };
}
- // When pressing escape a second time, blur the input and return to the tree view
- inputRef.current.blur();
+ // When pressing escape a second time return to the tree view
+ // The onKeyDown handler will also blur the input in this case
return { ...changes, isOpen: false, selectedItem: null };
}
case Downshift.stateChangeTypes.clickItem:
case Downshift.stateChangeTypes.keyDownEnter: {
if (isSearchResult(changes.selectedItem)) {
- const { id, refId } = changes.selectedItem.item;
- selectStory(id, refId);
// Return to the tree view, but keep the input value
- return { ...changes, inputValue: state.inputValue, isOpen: false };
+ return { ...changes, inputValue: state.inputValue };
}
if (isExpandType(changes.selectedItem)) {
- changes.selectedItem.showAll();
// Downshift should completely ignore this
- return {};
+ return state;
}
return changes;
}
- case Downshift.stateChangeTypes.changeInput: {
- // Reset the "show more" state whenever the input changes
- showAllComponents(false);
- return changes;
- }
-
default:
return changes;
}
},
- [inputRef, selectStory, showAllComponents]
+ []
);
const { isMobile } = useLayout();
@@ -298,6 +321,8 @@ export const Search = React.memo<{
// @ts-expect-error (Converted from ts-ignore)
itemToString={(result) => result?.item?.name || ''}
scrollIntoView={(e) => scrollIntoView(e)}
+ onSelect={onSelect}
+ onInputValueChange={onInputValueChange}
>
{({
isOpen,
@@ -343,6 +368,13 @@ export const Search = React.memo<{
setPlaceholder('Type to find...');
},
onBlur: () => setPlaceholder('Find components'),
+ onKeyDown: (e) => {
+ if (e.key === 'Escape' && inputValue.length === 0) {
+ // When pressing escape while the input is empty, blur the input
+ // The stateReducer will handle returning to the tree view
+ inputRef.current.blur();
+ }
+ },
});
const labelProps = getLabelProps({
@@ -352,32 +384,55 @@ export const Search = React.memo<{
return (
<>
Search for components
-
-
-
-
- {/* @ts-expect-error (TODO) */}
-
- {!isMobile && enableShortcuts && !isOpen && (
-
- {searchShortcut === '⌘ K' ? (
- <>
- ⌘ K
- >
- ) : (
- searchShortcut
- )}
-
- )}
- {isOpen && (
- clearSelection()}>
-
-
+
+
+
+
+
+
+ {!isMobile && enableShortcuts && !isOpen && (
+
+ {searchShortcut === '⌘ K' ? (
+ <>
+ ⌘ K
+ >
+ ) : (
+ searchShortcut
+ )}
+
+ )}
+ {isOpen && (
+ clearSelection()}>
+
+
+ )}
+
+ {showCreateStoryButton && (
+ <>
+ }
+ >
+ {
+ setIsFileSearchModalOpen(true);
+ }}
+ variant="outline"
+ >
+
+
+
+
+ >
)}
-
+