- `,
- },
-};
diff --git a/code/e2e-tests/framework-nextjs.spec.ts b/code/e2e-tests/framework-nextjs.spec.ts
index 61233dd5ac25..c1a15c2632ef 100644
--- a/code/e2e-tests/framework-nextjs.spec.ts
+++ b/code/e2e-tests/framework-nextjs.spec.ts
@@ -10,7 +10,7 @@ test.describe('Next.js', () => {
// TODO: improve these E2E tests given that we have more version of Next.js to test
// and this only tests nextjs/default-js
test.skip(
- !templateName?.includes('nextjs/default-js'),
+ !templateName?.includes('nextjs/default-ts'),
'Only run this test for the Frameworks that support next/navigation'
);
@@ -66,7 +66,7 @@ test.describe('Next.js', () => {
sbPage = new SbPage(page);
await sbPage.navigateToStory(
- 'stories/frameworks/nextjs-nextjs-default-js/Navigation',
+ 'stories/frameworks/nextjs-nextjs-default-ts/Navigation',
'default'
);
root = sbPage.previewRoot();
@@ -100,7 +100,7 @@ test.describe('Next.js', () => {
test.beforeEach(async ({ page }) => {
sbPage = new SbPage(page);
- await sbPage.navigateToStory('stories/frameworks/nextjs-nextjs-default-js/Router', 'default');
+ await sbPage.navigateToStory('stories/frameworks/nextjs-nextjs-default-ts/Router', 'default');
root = sbPage.previewRoot();
});
diff --git a/code/frameworks/nextjs/package.json b/code/frameworks/nextjs/package.json
index 65aebaa66c60..e2a136996b8c 100644
--- a/code/frameworks/nextjs/package.json
+++ b/code/frameworks/nextjs/package.json
@@ -75,19 +75,19 @@
"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/core": "^7.24.4",
"@babel/plugin-syntax-bigint": "^7.8.3",
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
- "@babel/plugin-syntax-import-assertions": "^7.22.5",
- "@babel/plugin-transform-class-properties": "^7.22.5",
- "@babel/plugin-transform-export-namespace-from": "^7.22.11",
- "@babel/plugin-transform-numeric-separator": "^7.22.11",
- "@babel/plugin-transform-object-rest-spread": "^7.22.15",
- "@babel/plugin-transform-runtime": "^7.23.2",
- "@babel/preset-env": "^7.23.2",
- "@babel/preset-react": "^7.22.15",
- "@babel/preset-typescript": "^7.23.2",
- "@babel/runtime": "^7.23.2",
+ "@babel/plugin-syntax-import-assertions": "^7.24.1",
+ "@babel/plugin-transform-class-properties": "^7.24.1",
+ "@babel/plugin-transform-export-namespace-from": "^7.24.1",
+ "@babel/plugin-transform-numeric-separator": "^7.24.1",
+ "@babel/plugin-transform-object-rest-spread": "^7.24.1",
+ "@babel/plugin-transform-runtime": "^7.24.3",
+ "@babel/preset-env": "^7.24.4",
+ "@babel/preset-react": "^7.24.1",
+ "@babel/preset-typescript": "^7.24.1",
+ "@babel/runtime": "^7.24.4",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.11",
"@storybook/addon-actions": "workspace:*",
"@storybook/builder-webpack5": "workspace:*",
@@ -121,7 +121,7 @@
"tsconfig-paths-webpack-plugin": "^4.0.1"
},
"devDependencies": {
- "@babel/types": "^7.23.0",
+ "@babel/types": "^7.24.0",
"@types/babel__core": "^7",
"@types/babel__plugin-transform-runtime": "^7",
"@types/babel__preset-env": "^7",
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/codemod/package.json b/code/lib/codemod/package.json
index 46f9ad028a5e..46037afa5e46 100644
--- a/code/lib/codemod/package.json
+++ b/code/lib/codemod/package.json
@@ -54,9 +54,9 @@
"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",
+ "@babel/core": "^7.24.4",
+ "@babel/preset-env": "^7.24.4",
+ "@babel/types": "^7.24.0",
"@storybook/csf": "^0.1.4",
"@storybook/csf-tools": "workspace:*",
"@storybook/node-logger": "workspace:*",
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..362ca55511d4
--- /dev/null
+++ b/code/lib/core-events/src/data/create-new-story.ts
@@ -0,0 +1,19 @@
+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;
+}
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
index 7c4c0295b388..998ebcdc0f3d 100644
--- a/code/lib/core-events/src/data/request-response.ts
+++ b/code/lib/core-events/src/data/request-response.ts
@@ -4,5 +4,5 @@ export type RequestData = {
};
export type ResponseData =
- | { id: string; success: true; payload: Payload }
- | { id: string; success: false; error?: string; payload?: Payload };
+ | { id: string; success: true; error: null; payload: Payload }
+ | { id: string; success: false; error: string; payload: null };
diff --git a/code/lib/core-events/src/index.ts b/code/lib/core-events/src/index.ts
index b8b21ff41f41..8ce72f12a8dd 100644
--- a/code/lib/core-events/src/index.ts
+++ b/code/lib/core-events/src/index.ts
@@ -74,8 +74,8 @@ enum events {
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',
@@ -98,8 +98,8 @@ export const {
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,
@@ -146,6 +146,8 @@ export const {
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';
diff --git a/code/lib/core-server/package.json b/code/lib/core-server/package.json
index b10fdf945447..f044cd25733e 100644
--- a/code/lib/core-server/package.json
+++ b/code/lib/core-server/package.json
@@ -56,7 +56,8 @@
},
"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:*",
@@ -79,12 +80,10 @@
"@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",
"diff": "^5.2.0",
- "es-module-lexer": "^1.5.0",
"express": "^4.17.3",
"fs-extra": "^11.1.0",
"globby": "^14.0.1",
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
index d9021723922d..12aa76d02f5d 100644
--- 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
@@ -3,6 +3,7 @@ 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,
@@ -63,9 +64,12 @@ describe('createNewStoryChannel', () => {
} as any);
mockChannel.emit(CREATE_NEW_STORYFILE_REQUEST, {
- componentFilePath: 'src/components/Page.jsx',
- componentExportName: 'Page',
- componentIsDefaultExport: true,
+ id: 'components-page--default',
+ payload: {
+ componentFilePath: 'src/components/Page.jsx',
+ componentExportName: 'Page',
+ componentIsDefaultExport: true,
+ },
});
await vi.waitFor(() => {
@@ -74,8 +78,11 @@ describe('createNewStoryChannel', () => {
expect(createNewStoryFileEventListener).toHaveBeenCalledWith({
error: null,
- result: {
+ id: 'components-page--default',
+ payload: {
storyId: 'components-page--default',
+ storyFilePath: path.join('src', 'components', 'Page.stories.jsx'),
+ exportedStoryName: 'Default',
},
success: true,
});
@@ -104,18 +111,23 @@ describe('createNewStoryChannel', () => {
} as any);
mockChannel.emit(CREATE_NEW_STORYFILE_REQUEST, {
- componentFilePath: 'src/components/Page.jsx',
- componentExportName: 'Page',
- componentIsDefaultExport: true,
- });
+ 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: 'An error occurred while creating a new story:\nFailed to write file',
- result: null,
+ error: 'Failed to write file',
+ payload: null,
+ 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
index 872b7fd08988..d11ab389ecc3 100644
--- 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
@@ -1,54 +1,64 @@
import type { Options } from '@storybook/types';
import type { Channel } from '@storybook/channels';
+import type {
+ 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 type { NewStoryData } from '../utils/get-new-story-file';
+import { existsSync } from 'node:fs';
import { getNewStoryFile } from '../utils/get-new-story-file';
import { getStoryId } from '../utils/get-story-id';
-
-interface CreateNewStoryPayload extends NewStoryData {}
-
-interface Result {
- success: true | false;
- result: null | {
- storyId: string;
- };
- error: null | string;
-}
+import path from 'node:path';
export function initCreateNewStoryChannel(channel: Channel, options: Options) {
/**
* Listens for events to create a new storyfile
*/
- channel.on(CREATE_NEW_STORYFILE_REQUEST, async (data: CreateNewStoryPayload) => {
- try {
- const { storyFilePath, exportedStoryName, storyFileContent } = await getNewStoryFile(
- data,
- options
- );
+ 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);
+
+ if (existsSync(storyFilePath)) {
+ throw new Error(`Story file already exists at ${relativeStoryFilePath}`);
+ }
- await fs.writeFile(storyFilePath, storyFileContent, 'utf-8');
+ await fs.writeFile(storyFilePath, storyFileContent, 'utf-8');
- const storyId = await getStoryId({ storyFilePath, exportedStoryName }, options);
+ const storyId = await getStoryId({ storyFilePath, exportedStoryName }, options);
- channel.emit(CREATE_NEW_STORYFILE_RESPONSE, {
- success: true,
- result: {
- storyId,
- },
- error: null,
- } satisfies Result);
- } catch (e: any) {
- channel.emit(CREATE_NEW_STORYFILE_RESPONSE, {
- success: false,
- result: null,
- error: `An error occurred while creating a new story:\n${e?.message}`,
- } satisfies Result);
+ channel.emit(CREATE_NEW_STORYFILE_RESPONSE, {
+ success: true,
+ id: data.id,
+ payload: {
+ storyId,
+ storyFilePath: path.relative(process.cwd(), storyFilePath),
+ exportedStoryName,
+ },
+ error: null,
+ } satisfies ResponseData);
+ } catch (e: any) {
+ channel.emit(CREATE_NEW_STORYFILE_RESPONSE, {
+ success: false,
+ id: data.id,
+ payload: null,
+ error: e?.message,
+ } satisfies ResponseData);
+ }
}
- });
+ );
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..0b4295ee145f 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);
- 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: false,
},
],
- searchQuery: 'commonjs',
},
success: true,
});
@@ -113,8 +116,11 @@ describe('file-search-channel', () => {
initFileSearchChannel(mockChannel, mockOptions as any);
- 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,
});
@@ -144,9 +150,12 @@ describe('file-search-channel', () => {
initFileSearchChannel(mockChannel, mockOptions as any);
- 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,10 @@ describe('file-search-channel', () => {
});
expect(searchResultChannelListener).toHaveBeenCalledWith({
+ id: data.searchQuery,
+ payload: null,
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..007091d222d0 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
@@ -7,100 +7,94 @@ import {
} from '@storybook/core-common';
import path from 'path';
import fs from 'fs/promises';
+import { existsSync } from 'fs';
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 { getStoryMetadata } from '../utils/get-new-story-file';
export function initFileSearchChannel(channel: Channel, options: Options) {
/**
* 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 = existsSync(path.join(projectRoot, dirname, storyFileName));
+ const info = await parser.parse(content);
- return {
- filepath: file,
- exportedComponents: info.exports,
- };
- } catch (e) {
- return {
- filepath: file,
- exportedComponents: null,
- };
- }
- });
+ return {
+ filepath: file,
+ exportedComponents: info.exports,
+ storyFileExists,
+ };
+ } catch (e) {
+ return {
+ filepath: file,
+ storyFileExists: false,
+ exportedComponents: null,
+ };
+ }
+ });
- 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 ?? '',
+ payload: null,
+ error: `An error occurred while searching for components in the project.\n${e?.message}`,
+ } satisfies ResponseData);
+ }
}
- });
+ );
return channel;
}
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-new-story-file.test.ts b/code/lib/core-server/src/utils/get-new-story-file.test.ts
index 429801ad0186..91b06d9027b0 100644
--- 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
@@ -17,6 +17,7 @@ describe('get-new-story-file', () => {
componentFilePath: 'src/components/Page.tsx',
componentExportName: 'Page',
componentIsDefaultExport: false,
+ componentExportCount: 1,
},
{
presets: {
@@ -31,7 +32,7 @@ describe('get-new-story-file', () => {
expect(exportedStoryName).toBe('Default');
expect(storyFileContent).toMatchInlineSnapshot(`
- "import type { Meta, StoryObj } from '@storybook/nextjs';
+ "import type { Meta, StoryObj } from '@storybook/react';
import { Page } from './Page';
@@ -54,6 +55,7 @@ describe('get-new-story-file', () => {
componentFilePath: 'src/components/Page.jsx',
componentExportName: 'Page',
componentIsDefaultExport: true,
+ componentExportCount: 1,
},
{
presets: {
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
index cfec1dd52152..be03c48d8e35 100644
--- a/code/lib/core-server/src/utils/get-new-story-file.ts
+++ b/code/lib/core-server/src/utils/get-new-story-file.ts
@@ -1,59 +1,78 @@
import type { Options } from '@storybook/types';
-import { getFrameworkName, getProjectRoot } from '@storybook/core-common';
+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';
-
-export interface NewStoryData {
- // 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;
-}
+import type { CreateNewStoryRequestPayload } from '@storybook/core-events';
export async function getNewStoryFile(
- { componentFilePath, componentExportName, componentIsDefaultExport }: NewStoryData,
+ {
+ componentFilePath,
+ componentExportName,
+ componentIsDefaultExport,
+ componentExportCount,
+ }: CreateNewStoryRequestPayload,
options: Options
) {
- const isTypescript = /\.(ts|tsx|mts|cts)$/.test(componentFilePath);
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 } = getStoryMetadata(componentFilePath);
const storyFileExtension = isTypescript ? 'tsx' : 'jsx';
- const storyFileName = `${basenameWithoutExtension}.stories.${storyFileExtension}`;
const alternativeStoryFileName = `${basenameWithoutExtension}.${componentExportName}.stories.${storyFileExtension}`;
const exportedStoryName = 'Default';
- const storyFileContent = isTypescript
- ? await getTypeScriptTemplateForNewStoryFile({
- basenameWithoutExtension,
- componentExportName,
- componentIsDefaultExport,
- frameworkPackageName,
- exportedStoryName,
- })
- : await getJavaScriptTemplateForNewStoryFile({
- basenameWithoutExtension,
- componentExportName,
- componentIsDefaultExport,
- exportedStoryName,
- });
-
- const doesStoryFileExist = fs.existsSync(path.join(cwd, componentFilePath));
-
- const storyFilePath = doesStoryFileExist
- ? path.join(cwd, dirname, alternativeStoryFileName)
- : path.join(cwd, dirname, storyFileName);
-
- return { storyFilePath, exportedStoryName, storyFileContent };
+ const storyFileContent =
+ isTypescript && rendererPackage
+ ? await getTypeScriptTemplateForNewStoryFile({
+ basenameWithoutExtension,
+ componentExportName,
+ componentIsDefaultExport,
+ rendererPackage,
+ exportedStoryName,
+ })
+ : await getJavaScriptTemplateForNewStoryFile({
+ basenameWithoutExtension,
+ componentExportName,
+ componentIsDefaultExport,
+ exportedStoryName,
+ });
+
+ const doesStoryFileExist = fs.existsSync(path.join(cwd, dirname, storyFileName));
+
+ const storyFilePath =
+ doesStoryFileExist && componentExportCount > 1
+ ? path.join(cwd, dirname, alternativeStoryFileName)
+ : path.join(cwd, dirname, storyFileName);
+
+ 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,
+ };
+};
diff --git a/code/lib/core-server/src/utils/get-story-id.ts b/code/lib/core-server/src/utils/get-story-id.ts
index acfbce990853..b874a071479a 100644
--- a/code/lib/core-server/src/utils/get-story-id.ts
+++ b/code/lib/core-server/src/utils/get-story-id.ts
@@ -31,9 +31,7 @@ export async function getStoryId(data: StoryIdData, options: Options) {
if (autoTitle === undefined) {
// eslint-disable-next-line local-rules/no-uncategorized-errors
throw new Error(dedent`
- The generation of your new Story file was successful but it seems that we are unable to index it.
- Please make sure that the new Story file is matched by the 'stories' glob pattern in your Storybook configuration.
- The location of the new Story file is: ${relativePath}
+ 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.
`);
}
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
index f576a3d4ad2d..338b3209ce95 100644
--- 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
@@ -7,12 +7,12 @@ describe('typescript', () => {
basenameWithoutExtension: 'foo',
componentExportName: 'default',
componentIsDefaultExport: true,
- frameworkPackageName: '@storybook/nextjs',
+ rendererPackage: '@storybook/react',
exportedStoryName: 'Default',
});
expect(result).toMatchInlineSnapshot(`
- "import type { Meta, StoryObj } from '@storybook/nextjs';
+ "import type { Meta, StoryObj } from '@storybook/react';
import Foo from './foo';
@@ -33,12 +33,12 @@ describe('typescript', () => {
basenameWithoutExtension: 'foo',
componentExportName: 'Example',
componentIsDefaultExport: false,
- frameworkPackageName: '@storybook/nextjs',
+ rendererPackage: '@storybook/react',
exportedStoryName: 'Default',
});
expect(result).toMatchInlineSnapshot(`
- "import type { Meta, StoryObj } from '@storybook/nextjs';
+ "import type { Meta, StoryObj } from '@storybook/react';
import { Example } from './foo';
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
index cb44dfdfc9c4..d2513673ebb5 100644
--- a/code/lib/core-server/src/utils/new-story-templates/typescript.ts
+++ b/code/lib/core-server/src/utils/new-story-templates/typescript.ts
@@ -6,8 +6,8 @@ interface TypeScriptTemplateData {
basenameWithoutExtension: string;
componentExportName: string;
componentIsDefaultExport: boolean;
- /** The framework package name, e.g. @storybook/nextjs */
- frameworkPackageName: string;
+ /** The renderer package name, e.g. @storybook/nextjs */
+ rendererPackage: string;
/** The exported name of the default story */
exportedStoryName: string;
}
@@ -21,7 +21,7 @@ export async function getTypeScriptTemplateForNewStoryFile(data: TypeScriptTempl
: `import { ${importName} } from './${data.basenameWithoutExtension}'`;
return dedent`
- import type { Meta, StoryObj } from '${data.frameworkPackageName}';
+ import type { Meta, StoryObj } from '${data.rendererPackage}';
${importStatement};
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/save-story/save-story.ts b/code/lib/core-server/src/utils/save-story/save-story.ts
index c87dc5ce131b..506be3147439 100644
--- a/code/lib/core-server/src/utils/save-story/save-story.ts
+++ b/code/lib/core-server/src/utils/save-story/save-story.ts
@@ -112,6 +112,7 @@ export function initializeSaveStory(channel: Channel, options: Options, coreConf
sourceFileName,
sourceStoryName,
},
+ error: null,
} satisfies ResponseData);
if (!coreConfig.disableTelemetry) {
@@ -124,14 +125,8 @@ export function initializeSaveStory(channel: Channel, options: Options, coreConf
channel.emit(SAVE_STORY_RESPONSE, {
id,
success: false,
- error: error instanceof SaveStoryError ? error.message : undefined,
- payload: {
- csfId,
- newStoryId,
- newStoryName,
- sourceFileName,
- sourceStoryName,
- },
+ error: error instanceof SaveStoryError ? error.message : 'Unknown error',
+ payload: null,
} satisfies ResponseData);
logger.error(
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/csf-tools/package.json b/code/lib/csf-tools/package.json
index 430b347c2451..0c76c17e7758 100644
--- a/code/lib/csf-tools/package.json
+++ b/code/lib/csf-tools/package.json
@@ -42,10 +42,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/generator": "^7.23.0",
- "@babel/parser": "^7.23.0",
- "@babel/traverse": "^7.23.2",
- "@babel/types": "^7.23.0",
+ "@babel/generator": "^7.24.4",
+ "@babel/parser": "^7.24.4",
+ "@babel/traverse": "^7.24.1",
+ "@babel/types": "^7.24.0",
"@storybook/csf": "^0.1.4",
"@storybook/types": "workspace:*",
"fs-extra": "^11.1.0",
diff --git a/code/lib/docs-tools/package.json b/code/lib/docs-tools/package.json
index 087113cd0229..5af72a730ae4 100644
--- a/code/lib/docs-tools/package.json
+++ b/code/lib/docs-tools/package.json
@@ -54,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/manager-api/src/lib/request-response.ts b/code/lib/manager-api/src/lib/request-response.ts
index dfbbda5b3b31..c52e8ad8da2d 100644
--- a/code/lib/manager-api/src/lib/request-response.ts
+++ b/code/lib/manager-api/src/lib/request-response.ts
@@ -4,11 +4,11 @@ import type { RequestData, ResponseData } from '@storybook/core-events';
class RequestResponseError extends Error {}
// eslint-disable-next-line @typescript-eslint/naming-convention
-export const experimental_requestResponse = (
+export const experimental_requestResponse = (
channel: Channel,
requestEvent: string,
responseEvent: string,
- payload: any,
+ payload: RequestPayload,
timeout = 5000
): Promise => {
let timeoutId: NodeJS.Timeout;
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 40596f6e1f21..69dace1ba1a3 100644
--- a/code/lib/preview-api/src/modules/preview-web/Preview.tsx
+++ b/code/lib/preview-api/src/modules/preview-web/Preview.tsx
@@ -312,11 +312,13 @@ export class Preview {
id,
success: true,
payload: { argTypes: story?.argTypes || {} },
+ error: null,
} satisfies ResponseData);
} catch (e: any) {
this.channel.emit(ARGTYPES_INFO_RESPONSE, {
id,
success: false,
+ payload: null,
error: e?.message,
} satisfies ResponseData);
}
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/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/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