Skip to content

Commit

Permalink
Merge pull request #26769 from storybookjs/valentin/create-story-file
Browse files Browse the repository at this point in the history
Controls: Added server channel to create a new story
  • Loading branch information
valentinpalkovic authored Apr 15, 2024
2 parents cfb552c + 154a302 commit ea87f40
Show file tree
Hide file tree
Showing 19 changed files with 630 additions and 0 deletions.
4 changes: 4 additions & 0 deletions code/lib/core-events/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ enum events {
TELEMETRY_ERROR = 'telemetryError',
FILE_COMPONENT_SEARCH = 'fileComponentSearch',
FILE_COMPONENT_SEARCH_RESULT = 'fileComponentSearchResult',
CREATE_NEW_STORYFILE = 'createNewStoryfile',
CREATE_NEW_STORYFILE_RESULT = 'createNewStoryfileResult',
}

// Enables: `import Events from ...`
Expand All @@ -86,6 +88,8 @@ export const {
CHANNEL_WS_DISCONNECT,
CHANNEL_CREATED,
CONFIG_ERROR,
CREATE_NEW_STORYFILE,
CREATE_NEW_STORYFILE_RESULT,
CURRENT_STORY_WAS_SET,
DOCS_PREPARED,
DOCS_RENDERED,
Expand Down
1 change: 1 addition & 0 deletions code/lib/core-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,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"
Expand Down
2 changes: 2 additions & 0 deletions code/lib/core-server/src/presets/common-preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { parseStaticDir } from '../utils/server-statics';
import { defaultStaticDirs } from '../utils/constants';
import { sendTelemetryError } from '../withTelemetry';
import { initFileSearchChannel } from '../server-channel/file-search-channel';
import { initCreateNewStoryChannel } from '../server-channel/create-new-story-channel';

const interpolate = (string: string, data: Record<string, string> = {}) =>
Object.entries(data).reduce((acc, [k, v]) => acc.replace(new RegExp(`%${k}%`, 'g'), v), string);
Expand Down Expand Up @@ -342,6 +343,7 @@ export const experimental_serverChannel = async (
});

initFileSearchChannel(channel, options);
initCreateNewStoryChannel(channel, options);

return channel;
};
Expand Down
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,
});
});
});
});
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 code/lib/core-server/src/utils/get-component-variable-name.test.ts
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 code/lib/core-server/src/utils/get-component-variable-name.ts
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;
};
83 changes: 83 additions & 0 deletions code/lib/core-server/src/utils/get-new-story-file.test.ts
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'));
});
});
59 changes: 59 additions & 0 deletions code/lib/core-server/src/utils/get-new-story-file.ts
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 };
}
Loading

0 comments on commit ea87f40

Please sign in to comment.