Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Controls: Added server channel to create a new story #26769

Merged
merged 12 commits into from
Apr 15, 2024
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',
valentinpalkovic marked this conversation as resolved.
Show resolved Hide resolved
}

// 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';
ghengeveld marked this conversation as resolved.
Show resolved Hide resolved
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 };
}
ghengeveld marked this conversation as resolved.
Show resolved Hide resolved
Loading