-
-
Notifications
You must be signed in to change notification settings - Fork 9.4k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #26769 from storybookjs/valentin/create-story-file
Controls: Added server channel to create a new story
- Loading branch information
Showing
19 changed files
with
630 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
120 changes: 120 additions & 0 deletions
120
code/lib/core-server/src/server-channel/create-new-story-channel.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,120 @@ | ||
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 { CREATE_NEW_STORYFILE, CREATE_NEW_STORYFILE_RESULT } from '@storybook/core-events'; | ||
|
||
vi.mock('@storybook/core-common', async (importOriginal) => { | ||
const actual = await importOriginal<typeof import('@storybook/core-common')>(); | ||
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<typeof import('node:fs/promises')>(); | ||
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_RESULT, 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); | ||
|
||
mockChannel.emit(CREATE_NEW_STORYFILE, { | ||
componentFilePath: 'src/components/Page.jsx', | ||
componentExportName: 'Page', | ||
componentIsDefaultExport: true, | ||
}); | ||
|
||
await vi.waitFor(() => { | ||
expect(createNewStoryFileEventListener).toHaveBeenCalled(); | ||
}); | ||
|
||
expect(createNewStoryFileEventListener).toHaveBeenCalledWith({ | ||
error: null, | ||
result: { | ||
storyId: 'components-page--default', | ||
}, | ||
success: true, | ||
}); | ||
}); | ||
|
||
it('should emit an error event if an error occurs', async () => { | ||
mockChannel.addListener(CREATE_NEW_STORYFILE_RESULT, 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); | ||
|
||
mockChannel.emit(CREATE_NEW_STORYFILE, { | ||
componentFilePath: 'src/components/Page.jsx', | ||
componentExportName: 'Page', | ||
componentIsDefaultExport: true, | ||
}); | ||
|
||
await vi.waitFor(() => { | ||
expect(createNewStoryFileEventListener).toHaveBeenCalled(); | ||
}); | ||
|
||
expect(createNewStoryFileEventListener).toHaveBeenCalledWith({ | ||
error: 'An error occurred while creating a new story:\nFailed to write file', | ||
result: null, | ||
success: false, | ||
}); | ||
}); | ||
}); | ||
}); |
51 changes: 51 additions & 0 deletions
51
code/lib/core-server/src/server-channel/create-new-story-channel.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,51 @@ | ||
import type { Options } from '@storybook/types'; | ||
import type { Channel } from '@storybook/channels'; | ||
import { CREATE_NEW_STORYFILE, CREATE_NEW_STORYFILE_RESULT } from '@storybook/core-events'; | ||
import fs from 'node:fs/promises'; | ||
import type { NewStoryData } from '../utils/get-new-story-file'; | ||
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; | ||
} | ||
|
||
export function initCreateNewStoryChannel(channel: Channel, options: Options) { | ||
/** | ||
* Listens for events to create a new storyfile | ||
*/ | ||
channel.on(CREATE_NEW_STORYFILE, async (data: CreateNewStoryPayload) => { | ||
try { | ||
const { storyFilePath, exportedStoryName, storyFileContent } = await getNewStoryFile( | ||
data, | ||
options | ||
); | ||
|
||
await fs.writeFile(storyFilePath, storyFileContent, 'utf-8'); | ||
|
||
const storyId = await getStoryId({ storyFilePath, exportedStoryName }, options); | ||
|
||
channel.emit(CREATE_NEW_STORYFILE_RESULT, { | ||
success: true, | ||
result: { | ||
storyId, | ||
}, | ||
error: null, | ||
} satisfies Result); | ||
} catch (e: any) { | ||
channel.emit(CREATE_NEW_STORYFILE_RESULT, { | ||
success: false, | ||
result: null, | ||
error: `An error occurred while creating a new story:\n${e?.message}`, | ||
} satisfies Result); | ||
} | ||
}); | ||
|
||
return channel; | ||
} |
11 changes: 11 additions & 0 deletions
11
code/lib/core-server/src/utils/get-component-variable-name.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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$'); | ||
}); | ||
}); |
12 changes: 12 additions & 0 deletions
12
code/lib/core-server/src/utils/get-component-variable-name.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,83 @@ | ||
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<typeof import('@storybook/core-common')>(); | ||
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, | ||
}, | ||
{ | ||
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/nextjs'; | ||
import { Page } from './Page'; | ||
const meta = { | ||
component: Page, | ||
} satisfies Meta<typeof Page>; | ||
export default meta; | ||
type Story = StoryObj<typeof meta>; | ||
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, | ||
}, | ||
{ | ||
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')); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,59 @@ | ||
import type { Options } from '@storybook/types'; | ||
import { getFrameworkName, getProjectRoot } 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; | ||
} | ||
|
||
export async function getNewStoryFile( | ||
{ componentFilePath, componentExportName, componentIsDefaultExport }: NewStoryData, | ||
options: Options | ||
) { | ||
const isTypescript = /\.(ts|tsx|mts|cts)$/.test(componentFilePath); | ||
const cwd = getProjectRoot(); | ||
|
||
const frameworkPackageName = await getFrameworkName(options); | ||
|
||
const basename = path.basename(componentFilePath); | ||
const extension = path.extname(componentFilePath); | ||
const basenameWithoutExtension = basename.replace(extension, ''); | ||
const dirname = path.dirname(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 }; | ||
} |
Oops, something went wrong.