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
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;
}
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 Component from './Page';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would make sense to use the value of componentExportName here, rather than Component. Otherwise why specify componentExportName at all?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

componentExportName for a default export would be default. And since default is a reserved keyword, I would suggest using a static string instead. It doesn't have to be Component, though.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I thought componentExportName would be the file's base name if there is no named export. That's what the test above suggested. Either way, can we use the file's base name (converted to PascalCase) rather than Component?


const meta = {
component: Component,
};

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
? getTypeScriptTemplateForNewStoryFile({
basenameWithoutExtension,
componentExportName,
componentIsDefaultExport,
frameworkPackageName,
exportedStoryName,
})
: 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
45 changes: 45 additions & 0 deletions code/lib/core-server/src/utils/get-story-id.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import path from 'path';
import { describe, expect, it } from 'vitest';
import { getStoryId } from './get-story-id';

describe('getStoryId', () => {
it('should return the storyId', async () => {
const cwd = process.cwd();
const options = {
configDir: path.join(cwd, '.storybook'),
presets: {
apply: (val: string) => {
if (val === 'stories') {
return Promise.resolve(['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)']);
}
},
},
} as any;
const storyFilePath = path.join(cwd, 'src', 'components', 'stories', 'Page1.stories.ts');
const exportedStoryName = 'Default';

const storyId = await getStoryId({ storyFilePath, exportedStoryName }, options);

expect(storyId).toBe('components-stories-page1--default');
});

it('should throw an error if the storyId cannot be calculated', async () => {
const cwd = process.cwd();
const options = {
configDir: path.join(cwd, '.storybook'),
presets: {
apply: (val: string) => {
if (val === 'stories') {
return Promise.resolve(['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)']);
}
},
},
} as any;
const storyFilePath = path.join(cwd, 'not-covered-path', 'stories', 'Page1.stories.ts');
const exportedStoryName = 'Default';

await expect(() =>
getStoryId({ storyFilePath, exportedStoryName }, options)
).rejects.toThrowError();
});
});
Loading