diff --git a/code/addons/docs/template/stories/docs2/multiple-csf-files-a.stories.ts b/code/addons/docs/template/stories/docs2/multiple-csf-files-a.stories.ts new file mode 100644 index 00000000000..c77284a296f --- /dev/null +++ b/code/addons/docs/template/stories/docs2/multiple-csf-files-a.stories.ts @@ -0,0 +1,23 @@ +import { global as globalThis } from '@storybook/global'; + +export default { + title: 'Multiple CSF Files Same Title', + component: globalThis.Components.Html, + tags: ['autodocs'], + args: { + content: '

paragraph

', + }, + parameters: { + chromatic: { disable: true }, + }, +}; + +export const DefaultA = {}; + +export const SpanContent = { + args: { content: 'span' }, +}; + +export const CodeContent = { + args: { content: 'code' }, +}; diff --git a/code/addons/docs/template/stories/docs2/multiple-csf-files-b.stories.ts b/code/addons/docs/template/stories/docs2/multiple-csf-files-b.stories.ts new file mode 100644 index 00000000000..955c04af9f9 --- /dev/null +++ b/code/addons/docs/template/stories/docs2/multiple-csf-files-b.stories.ts @@ -0,0 +1,23 @@ +import { global as globalThis } from '@storybook/global'; + +export default { + title: 'Multiple CSF Files Same Title', + component: globalThis.Components.Html, + tags: ['autodocs'], + args: { + content: '

paragraph

', + }, + parameters: { + chromatic: { disable: true }, + }, +}; + +export const DefaultB = {}; + +export const H1Content = { + args: { content: '

heading 1

' }, +}; + +export const H2Content = { + args: { content: '

heading 2

' }, +}; diff --git a/code/e2e-tests/addon-docs.spec.ts b/code/e2e-tests/addon-docs.spec.ts index 4ae3da33e99..72470acb62a 100644 --- a/code/e2e-tests/addon-docs.spec.ts +++ b/code/e2e-tests/addon-docs.spec.ts @@ -210,4 +210,19 @@ test.describe('addon-docs', () => { await expect(componentReactVersion).toHaveText(expectedReactVersion); await expect(componentReactDomVersion).toHaveText(expectedReactVersion); }); + + test('should have stories from multiple CSF files in autodocs', async ({ page }) => { + const sbPage = new SbPage(page); + await sbPage.navigateToStory('/addons/docs/multiple-csf-files-same-title', 'docs'); + const root = sbPage.previewRoot(); + + const storyHeadings = root.locator('.sb-anchor > h3'); + await expect(await storyHeadings.count()).toBe(6); + await expect(storyHeadings.nth(0)).toHaveText('Default A'); + await expect(storyHeadings.nth(1)).toHaveText('Span Content'); + await expect(storyHeadings.nth(2)).toHaveText('Code Content'); + await expect(storyHeadings.nth(3)).toHaveText('Default B'); + await expect(storyHeadings.nth(4)).toHaveText('H 1 Content'); + await expect(storyHeadings.nth(5)).toHaveText('H 2 Content'); + }); }); diff --git a/code/lib/cli/src/autoblock/block-dependencies-versions.ts b/code/lib/cli/src/autoblock/block-dependencies-versions.ts index fb052e60c62..45b5181dee0 100644 --- a/code/lib/cli/src/autoblock/block-dependencies-versions.ts +++ b/code/lib/cli/src/autoblock/block-dependencies-versions.ts @@ -9,6 +9,7 @@ const minimalVersionsMap = { preact: '10.0.0', svelte: '4.0.0', vue: '3.0.0', + vite: '4.0.0', }; type Result = { @@ -80,7 +81,7 @@ export const blocker = createBlocker({ default: return dedent` Support for ${data.packageName} version < ${data.minimumVersion} has been removed. - Storybook 8 needs minimum version of ${data.minimumVersion}, but you had version ${data.installedVersion}. + Since version 8, Storybook needs a minimum version of ${data.minimumVersion}, but you have version ${data.installedVersion}. Please update this dependency. `; diff --git a/code/lib/cli/src/autoblock/block-stories-mdx.ts b/code/lib/cli/src/autoblock/block-stories-mdx.ts deleted file mode 100644 index 3c1fadeda35..00000000000 --- a/code/lib/cli/src/autoblock/block-stories-mdx.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { createBlocker } from './types'; -import { dedent } from 'ts-dedent'; -import { glob } from 'glob'; - -export const blocker = createBlocker({ - id: 'storiesMdxUsage', - async check() { - const files = await glob('**/*.stories.mdx', { cwd: process.cwd() }); - if (files.length === 0) { - return false; - } - return { files }; - }, - log(options, data) { - return dedent` - Support for *.stories.mdx files has been removed. - Please see the migration guide for more information: - https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#dropping-support-for-storiesmdx-csf-in-mdx-format-and-mdx1-support - - Storybook will also require you to use MDX 3.0.0 or later. - Check the migration guide for more information: - https://mdxjs.com/blog/v3/ - - Found ${data.files.length} stories.mdx ${ - data.files.length === 1 ? 'file' : 'files' - }, these must be migrated. - - Manually run the migration script to convert your stories.mdx files to CSF format documented here: - https://storybook.js.org/docs/migration-guide#storiesmdx-to-mdxcsf - `; - }, -}); diff --git a/code/lib/cli/src/autoblock/index.ts b/code/lib/cli/src/autoblock/index.ts index a6c45a2318b..23b38721269 100644 --- a/code/lib/cli/src/autoblock/index.ts +++ b/code/lib/cli/src/autoblock/index.ts @@ -8,7 +8,6 @@ const excludesFalse = (x: T | false): x is T => x !== false; const blockers: () => BlockerModule[] = () => [ // add/remove blockers here import('./block-storystorev6'), - import('./block-stories-mdx'), import('./block-dependencies-versions'), import('./block-node-version'), ]; diff --git a/code/lib/cli/src/automigrate/fixes/__snapshots__/angular-builders.test.ts.snap b/code/lib/cli/src/automigrate/fixes/__snapshots__/angular-builders.test.ts.snap deleted file mode 100644 index 0c9ea612b97..00000000000 --- a/code/lib/cli/src/automigrate/fixes/__snapshots__/angular-builders.test.ts.snap +++ /dev/null @@ -1,6 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`is not Nx project > angular builders > Angular < 15.0.0 > should throw an Error 1`] = ` -[Error: ❌ Your project uses Angular < 15.0.0. Storybook 8.0 for Angular requires Angular 15.0.0 or higher. -Please upgrade your Angular version to at least version 15.0.0 to use Storybook 8.0 in your project.] -`; diff --git a/code/lib/cli/src/automigrate/fixes/angular-builders-multiproject.ts b/code/lib/cli/src/automigrate/fixes/angular-builders-multiproject.ts index d57fbd28a10..3f72411be01 100644 --- a/code/lib/cli/src/automigrate/fixes/angular-builders-multiproject.ts +++ b/code/lib/cli/src/automigrate/fixes/angular-builders-multiproject.ts @@ -12,6 +12,8 @@ export const angularBuildersMultiproject: Fix=7'], + async check({ packageManager, mainConfig }) { // Skip in case of NX const angularVersion = await packageManager.getPackageVersion('@angular/core'); diff --git a/code/lib/cli/src/automigrate/fixes/angular-builders.test.ts b/code/lib/cli/src/automigrate/fixes/angular-builders.test.ts index 2b9623e26d9..e3825b0dae6 100644 --- a/code/lib/cli/src/automigrate/fixes/angular-builders.test.ts +++ b/code/lib/cli/src/automigrate/fixes/angular-builders.test.ts @@ -71,24 +71,6 @@ describe('is not Nx project', () => { }); }); - describe('Angular < 15.0.0', () => { - const packageManager = { - getPackageVersion: (packageName: string) => { - if (packageName === '@angular/core') { - return Promise.resolve('14.0.0'); - } - - return null; - }, - } as Partial; - - it('should throw an Error', async () => { - await expect( - checkAngularBuilders({ packageManager, mainConfig: { framework: '@storybook/angular' } }) - ).rejects.toThrowErrorMatchingSnapshot(); - }); - }); - describe('Angular >= 16.0.0', () => { const packageManager = { getPackageVersion: (packageName) => { diff --git a/code/lib/cli/src/automigrate/fixes/angular-builders.ts b/code/lib/cli/src/automigrate/fixes/angular-builders.ts index b0c40a0f699..ac2f2af99d2 100644 --- a/code/lib/cli/src/automigrate/fixes/angular-builders.ts +++ b/code/lib/cli/src/automigrate/fixes/angular-builders.ts @@ -1,5 +1,4 @@ import { dedent } from 'ts-dedent'; -import semver from 'semver'; import type { StorybookConfig } from '@storybook/types'; import chalk from 'chalk'; import prompts from 'prompts'; @@ -17,6 +16,8 @@ interface AngularBuildersRunOptions { export const angularBuilders: Fix = { id: 'angular-builders', + versionRange: ['<7', '>=7'], + async check({ packageManager, mainConfig }) { const angularVersion = await packageManager.getPackageVersion('@angular/core'); @@ -31,13 +32,6 @@ export const angularBuilders: Fix = { return null; } - if (semver.lt(angularVersion, '15.0.0')) { - throw new Error(dedent` - ❌ Your project uses Angular < 15.0.0. Storybook 8.0 for Angular requires Angular 15.0.0 or higher. - Please upgrade your Angular version to at least version 15.0.0 to use Storybook 8.0 in your project. - `); - } - const angularJSON = new AngularJSON(); const { hasStorybookBuilder } = angularJSON; diff --git a/code/lib/cli/src/automigrate/fixes/autodocs-true.ts b/code/lib/cli/src/automigrate/fixes/autodocs-true.ts index f84c3f7ee77..e5c2aceda7c 100644 --- a/code/lib/cli/src/automigrate/fixes/autodocs-true.ts +++ b/code/lib/cli/src/automigrate/fixes/autodocs-true.ts @@ -16,6 +16,8 @@ interface AutodocsTrueFrameworkRunOptions { export const autodocsTrue: Fix = { id: 'autodocsTrue', + versionRange: ['<7', '>=7'], + async check({ mainConfig }) { const { docs } = mainConfig; diff --git a/code/lib/cli/src/automigrate/fixes/bare-mdx-stories-glob.test.ts b/code/lib/cli/src/automigrate/fixes/bare-mdx-stories-glob.test.ts index 27154a2a63a..c2574fa2d1e 100644 --- a/code/lib/cli/src/automigrate/fixes/bare-mdx-stories-glob.test.ts +++ b/code/lib/cli/src/automigrate/fixes/bare-mdx-stories-glob.test.ts @@ -29,16 +29,6 @@ describe('bare-mdx fix', () => { }); describe('should no-op', () => { - it('in SB < v7.0.0', async () => { - const packageJson = { - dependencies: { '@storybook/react': '^6.2.0' }, - }; - const main = { stories: ['../**/*.stories.mdx'] }; - await expect( - checkBareMdxStoriesGlob({ packageJson, main, storybookVersion: '6.5.0' }) - ).resolves.toBeFalsy(); - }); - describe('in SB >= v7.0.0', () => { it('without main', async () => { const packageJson = { @@ -162,7 +152,7 @@ describe('bare-mdx fix', () => { } In Storybook 7, we have deprecated defining stories in MDX files, and consequently have changed the suffix to simply .mdx. - + Now, since Storybook 8.0, we have removed support for .stories.mdx files. We can automatically migrate your 'stories' config to include any .mdx file instead of just .stories.mdx. That would result in the following 'stories' config: "../src/**/*.mdx" @@ -171,7 +161,6 @@ describe('bare-mdx fix', () => { "directory": "../src/**", "files": "*.mdx" } - To learn more about this change, see: https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#mdx-docs-files" `); }); diff --git a/code/lib/cli/src/automigrate/fixes/bare-mdx-stories-glob.ts b/code/lib/cli/src/automigrate/fixes/bare-mdx-stories-glob.tsx similarity index 95% rename from code/lib/cli/src/automigrate/fixes/bare-mdx-stories-glob.ts rename to code/lib/cli/src/automigrate/fixes/bare-mdx-stories-glob.tsx index 175e69c23b1..28fb62c6b63 100644 --- a/code/lib/cli/src/automigrate/fixes/bare-mdx-stories-glob.ts +++ b/code/lib/cli/src/automigrate/fixes/bare-mdx-stories-glob.tsx @@ -1,6 +1,5 @@ import chalk from 'chalk'; import dedent from 'ts-dedent'; -import semver from 'semver'; import type { StoriesEntry } from '@storybook/types'; import { updateMainConfig } from '../helpers/mainConfigFile'; import type { Fix } from '../types'; @@ -31,11 +30,8 @@ const getNextGlob = (glob: string) => { export const bareMdxStoriesGlob: Fix = { id: 'bare-mdx-stories-glob', - async check({ storybookVersion, mainConfig }) { - if (!semver.gte(storybookVersion, '7.0.0')) { - return null; - } - + versionRange: ['<7', '>=7'], + async check({ mainConfig }) { const existingStoriesEntries = mainConfig.stories as StoriesEntry[]; if (!existingStoriesEntries) { @@ -45,10 +41,9 @@ export const bareMdxStoriesGlob: Fix = { )}, skipping ${chalk.cyan(this.id)} fix. In Storybook 7, we have deprecated defining stories in MDX files, and consequently have changed the suffix to simply .mdx. - + Now, since Storybook 8.0, we have removed support for .stories.mdx files. We were unable to automatically migrate your 'stories' config to include any .mdx file instead of just .stories.mdx. We suggest you make this change manually. - To learn more about this change, see: ${chalk.yellow( 'https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#mdx-docs-files' )} @@ -100,11 +95,10 @@ export const bareMdxStoriesGlob: Fix = { ${chalk.cyan(prettyExistingStoriesEntries)} In Storybook 7, we have deprecated defining stories in MDX files, and consequently have changed the suffix to simply .mdx. - + Now, since Storybook 8.0, we have removed support for .stories.mdx files. We can automatically migrate your 'stories' config to include any .mdx file instead of just .stories.mdx. That would result in the following 'stories' config: ${chalk.cyan(prettyNextStoriesEntries)} - To learn more about this change, see: ${chalk.yellow( 'https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#mdx-docs-files' )} diff --git a/code/lib/cli/src/automigrate/fixes/builder-vite.ts b/code/lib/cli/src/automigrate/fixes/builder-vite.ts index 8662bb6cc21..b38cf9ccb67 100644 --- a/code/lib/cli/src/automigrate/fixes/builder-vite.ts +++ b/code/lib/cli/src/automigrate/fixes/builder-vite.ts @@ -27,6 +27,8 @@ interface BuilderViteOptions { export const builderVite: Fix = { id: 'builder-vite', + versionRange: ['<7', '>=7'], + async check({ packageManager, mainConfig }) { const packageJson = await packageManager.retrievePackageJson(); const builder = mainConfig.core?.builder; diff --git a/code/lib/cli/src/automigrate/fixes/cra5.ts b/code/lib/cli/src/automigrate/fixes/cra5.ts index d3786cd2d00..468fbe90947 100644 --- a/code/lib/cli/src/automigrate/fixes/cra5.ts +++ b/code/lib/cli/src/automigrate/fixes/cra5.ts @@ -20,6 +20,8 @@ interface CRA5RunOptions { export const cra5: Fix = { id: 'cra5', + versionRange: ['<7', '>=7'], + async check({ packageManager, mainConfig, storybookVersion }) { const craVersion = await packageManager.getPackageVersion('react-scripts'); diff --git a/code/lib/cli/src/automigrate/fixes/eslint-plugin.ts b/code/lib/cli/src/automigrate/fixes/eslint-plugin.ts index 25e5dfd0320..de81e08008d 100644 --- a/code/lib/cli/src/automigrate/fixes/eslint-plugin.ts +++ b/code/lib/cli/src/automigrate/fixes/eslint-plugin.ts @@ -26,6 +26,8 @@ interface EslintPluginRunOptions { export const eslintPlugin: Fix = { id: 'eslintPlugin', + versionRange: ['<8', '>=7'], + async check({ packageManager }) { const { hasEslint, isStorybookPluginInstalled } = await extractEslintInfo(packageManager); diff --git a/code/lib/cli/src/automigrate/fixes/incompatible-addons.ts b/code/lib/cli/src/automigrate/fixes/incompatible-addons.ts index 469383834cc..fdf1c967876 100644 --- a/code/lib/cli/src/automigrate/fixes/incompatible-addons.ts +++ b/code/lib/cli/src/automigrate/fixes/incompatible-addons.ts @@ -10,6 +10,7 @@ interface IncompatibleAddonsOptions { export const incompatibleAddons: Fix = { id: 'incompatible-addons', promptType: 'manual', + versionRange: ['*', '*'], async check({ mainConfig, packageManager }) { const incompatibleAddonList = await getIncompatibleAddons(mainConfig, packageManager); @@ -20,14 +21,14 @@ export const incompatibleAddons: Fix = { return dedent` ${chalk.bold( 'Attention' - )}: We've detected that you're using the following addons in versions which are known to be incompatible with Storybook 7: + )}: We've detected that you're using the following addons in versions which are known to be incompatible with Storybook 8: ${incompatibleAddonList .map(({ name, version }) => `- ${chalk.cyan(`${name}@${version}`)}`) .join('\n')} - Please be aware they might not work in Storybook 7. Reach out to their maintainers for updates and check the following Github issue for more information: - ${chalk.yellow('https://github.com/storybookjs/storybook/issues/20529')} + Please be aware they might not work in Storybook 8. Reach out to their maintainers for updates and check the following Github issue for more information: + ${chalk.yellow('https://github.com/storybookjs/storybook/issues/26031')} `; }, }; diff --git a/code/lib/cli/src/automigrate/fixes/index.ts b/code/lib/cli/src/automigrate/fixes/index.ts index 68b642586f2..48e472c5220 100644 --- a/code/lib/cli/src/automigrate/fixes/index.ts +++ b/code/lib/cli/src/automigrate/fixes/index.ts @@ -2,7 +2,6 @@ import type { Fix } from '../types'; import { cra5 } from './cra5'; import { webpack5 } from './webpack5'; -import { vite4 } from './vite4'; import { vue3 } from './vue3'; import { mdxgfm } from './mdx-gfm'; import { removeLegacyMDX1 } from './remove-legacymdx1'; @@ -13,10 +12,8 @@ import { sbScripts } from './sb-scripts'; import { sbBinary } from './sb-binary'; import { newFrameworks } from './new-frameworks'; import { removedGlobalClientAPIs } from './remove-global-client-apis'; -import { mdx1to2 } from './mdx-1-to-2'; import { autodocsTrue } from './autodocs-true'; import { angularBuilders } from './angular-builders'; -import { incompatibleAddons } from './incompatible-addons'; import { angularBuildersMultiproject } from './angular-builders-multiproject'; import { wrapRequire } from './wrap-require'; import { reactDocgen } from './react-docgen'; @@ -25,6 +22,7 @@ import { storyshotsMigration } from './storyshots-migration'; import { removeArgtypesRegex } from './remove-argtypes-regex'; import { webpack5CompilerSetup } from './webpack5-compiler-setup'; import { removeJestTestingLibrary } from './remove-jest-testing-library'; +import { mdx1to3 } from './mdx-1-to-3'; export * from '../types'; @@ -33,17 +31,14 @@ export const allFixes: Fix[] = [ cra5, webpack5, vue3, - vite4, viteConfigFile, eslintPlugin, builderVite, sbBinary, sbScripts, - incompatibleAddons, removeArgtypesRegex, removeJestTestingLibrary, removedGlobalClientAPIs, - mdx1to2, mdxgfm, autodocsTrue, angularBuildersMultiproject, @@ -54,6 +49,7 @@ export const allFixes: Fix[] = [ removeReactDependency, removeLegacyMDX1, webpack5CompilerSetup, + mdx1to3, ]; export const initFixes: Fix[] = [eslintPlugin]; diff --git a/code/lib/cli/src/automigrate/fixes/mdx-1-to-2.test.ts b/code/lib/cli/src/automigrate/fixes/mdx-1-to-3.test.ts similarity index 96% rename from code/lib/cli/src/automigrate/fixes/mdx-1-to-2.test.ts rename to code/lib/cli/src/automigrate/fixes/mdx-1-to-3.test.ts index c471d478fdc..320e4447de7 100644 --- a/code/lib/cli/src/automigrate/fixes/mdx-1-to-2.test.ts +++ b/code/lib/cli/src/automigrate/fixes/mdx-1-to-3.test.ts @@ -1,7 +1,7 @@ import { it, expect } from 'vitest'; import { dedent } from 'ts-dedent'; -import { fixMdxStyleTags, fixMdxComments } from './mdx-1-to-2'; +import { fixMdxStyleTags, fixMdxComments } from './mdx-1-to-3'; it('fixMdxStyleTags fixes badly-formatted style blocks', () => { expect( diff --git a/code/lib/cli/src/automigrate/fixes/mdx-1-to-2.ts b/code/lib/cli/src/automigrate/fixes/mdx-1-to-3.ts similarity index 85% rename from code/lib/cli/src/automigrate/fixes/mdx-1-to-2.ts rename to code/lib/cli/src/automigrate/fixes/mdx-1-to-3.ts index 108dde3c5aa..3d81d093444 100644 --- a/code/lib/cli/src/automigrate/fixes/mdx-1-to-2.ts +++ b/code/lib/cli/src/automigrate/fixes/mdx-1-to-3.ts @@ -31,7 +31,7 @@ export const fixMdxComments = (mdx: string) => { const logger = console; -interface Mdx1to2Options { +interface Mdx1to3Options { storiesMdxFiles: string[]; } @@ -40,10 +40,12 @@ interface Mdx1to2Options { * * If so: * - Assume they might be MDX1 - * - Offer to help migrate to MDX2 + * - Offer to help migrate to MDX3 */ -export const mdx1to2: Fix = { - id: 'mdx1to2', +export const mdx1to3: Fix = { + id: 'mdx1to3', + + versionRange: ['<7.0.0', '>=8.0.0-alpha.0'], async check() { const storiesMdxFiles = await globby('./!(node_modules)**/*.(story|stories).mdx'); @@ -54,8 +56,8 @@ export const mdx1to2: Fix = { return dedent` We've found ${chalk.yellow(storiesMdxFiles.length)} '.stories.mdx' files in your project. - Storybook has upgraded to MDX2 (https://mdxjs.com/blog/v2/), which contains breaking changes from MDX1. - We can try to automatically upgrade your MDX files to MDX2 format using some common patterns. + Storybook has upgraded to MDX3 (https://mdxjs.com/blog/v3/). MDX3 itself doesn't contain disruptive breaking changes, whereas the transition from MDX1 to MDX2 was a significant change. + We can try to automatically upgrade your MDX files to MDX3 format using some common patterns. After this install completes, and before you start Storybook, we strongly recommend reading the MDX2 section of the 7.0 migration guide. It contains useful tools for detecting and fixing any remaining issues. diff --git a/code/lib/cli/src/automigrate/fixes/mdx-gfm.ts b/code/lib/cli/src/automigrate/fixes/mdx-gfm.ts index 7fc07b63349..98bcaa736b8 100644 --- a/code/lib/cli/src/automigrate/fixes/mdx-gfm.ts +++ b/code/lib/cli/src/automigrate/fixes/mdx-gfm.ts @@ -1,5 +1,4 @@ import { dedent } from 'ts-dedent'; -import semver from 'semver'; import { join } from 'path'; import slash from 'slash'; import glob from 'globby'; @@ -19,11 +18,9 @@ interface Options { export const mdxgfm: Fix = { id: 'github-flavored-markdown-mdx', - async check({ configDir, mainConfig, storybookVersion }) { - if (!semver.gte(storybookVersion, '7.0.0')) { - return null; - } + versionRange: ['<7', '>=7'], + async check({ configDir, mainConfig }) { const hasMDXFiles = await mainConfig?.stories?.reduce(async (acc, item) => { const val = await acc; @@ -83,11 +80,11 @@ export const mdxgfm: Fix = { return dedent` In MDX1 you had the option of using GitHub flavored markdown. - Storybook 8.0 uses MDX3 for compiling MDX, and thus no longer supports GFM out of the box. + Storybook >= 8.0 uses MDX3 for compiling MDX, and thus no longer supports GFM out of the box. Because of this you need to explicitly add the GFM plugin in the addon-docs options: https://storybook.js.org/docs/react/writing-docs/mdx#lack-of-github-flavored-markdown-gfm - We recommend you follow the guide on the link above, however we can add a temporary storybook addon that helps make this migration easier. + We recommend that you follow the guide in the link above; however, we can add a temporary Storybook addon to help make this migration easier. We'll install the addon and add it to your storybook config. `; }, diff --git a/code/lib/cli/src/automigrate/fixes/new-frameworks.test.ts b/code/lib/cli/src/automigrate/fixes/new-frameworks.test.ts index 4cc2669b91a..96099855df7 100644 --- a/code/lib/cli/src/automigrate/fixes/new-frameworks.test.ts +++ b/code/lib/cli/src/automigrate/fixes/new-frameworks.test.ts @@ -57,19 +57,6 @@ const getPackageManager = (packages: Record) => { describe('new-frameworks fix', () => { describe('should no-op', () => { - it('in sb < 7', async () => { - const packageManager = getPackageManager({ - '@storybook/vue': '6.2.0', - }); - - await expect( - checkNewFrameworks({ - packageManager, - storybookVersion: '6.2.0', - }) - ).resolves.toBeFalsy(); - }); - it('in sb 7 with correct structure already', async () => { const packageManager = getPackageManager({ '@storybook/angular': '7.0.0', diff --git a/code/lib/cli/src/automigrate/fixes/new-frameworks.ts b/code/lib/cli/src/automigrate/fixes/new-frameworks.ts index f335d654050..97ace48c8ee 100644 --- a/code/lib/cli/src/automigrate/fixes/new-frameworks.ts +++ b/code/lib/cli/src/automigrate/fixes/new-frameworks.ts @@ -58,18 +58,9 @@ interface NewFrameworkRunOptions { export const newFrameworks: Fix = { id: 'new-frameworks', - async check({ - configDir, - packageManager, - storybookVersion, - mainConfig, - mainConfigPath, - rendererPackage, - }) { - if (!semver.gte(storybookVersion, '7.0.0')) { - return null; - } + versionRange: ['<7', '>=7'], + async check({ configDir, packageManager, mainConfig, mainConfigPath, rendererPackage }) { if (typeof configDir === 'undefined') { return null; } @@ -219,7 +210,7 @@ export const newFrameworks: Fix = { newFrameworkPackage )}, but we detected that you are using Vite ${chalk.bold( viteVersion - )}, which is unsupported in ${chalk.bold( + )}, which is unsupported since ${chalk.bold( 'Storybook 7.0' )}. Please upgrade Vite to ${chalk.bold('3.0.0 or higher')} and rerun this migration. `); @@ -406,7 +397,7 @@ export const newFrameworks: Fix = { } return dedent` - We've detected your project is not fully setup with Storybook's 7 new framework format. + We've detected your project is not fully setup with the new framework format, which was introduced in Storybook 7. Storybook 7 introduced the concept of frameworks, which abstracts configuration for renderers (e.g. React, Vue), builders (e.g. Webpack, Vite) and defaults to make integrations easier. diff --git a/code/lib/cli/src/automigrate/fixes/prompt-remove-react.test.ts b/code/lib/cli/src/automigrate/fixes/prompt-remove-react.test.ts index 7d33c0fe90a..baf9922b5f0 100644 --- a/code/lib/cli/src/automigrate/fixes/prompt-remove-react.test.ts +++ b/code/lib/cli/src/automigrate/fixes/prompt-remove-react.test.ts @@ -9,15 +9,12 @@ const check = async ({ main: mainConfig, storybookVersion = '8.0.0', }: { - packageManagerContent: Pick< - Partial>>, - 'dependencies' | 'devDependencies' | 'peerDependencies' - >; + packageManagerContent: Partial>>; main: Partial & Record; storybookVersion?: string; }) => { const packageManager = { - retrievePackageJson: async () => packageManagerContent, + getAllDependencies: async () => packageManagerContent, } as JsPackageManager; return removeReactDependency.check({ @@ -31,21 +28,6 @@ const check = async ({ vi.mock('glob', () => ({ glob: vi.fn(() => []) })); describe('early exits', () => { - it('cancel if storybookVersion < 8', async () => { - await expect( - check({ - packageManagerContent: { - dependencies: { react: '16.0.0' }, - }, - main: { - stories: [], - framework: '@storybook/vue-vite', - }, - storybookVersion: '7.0.0', - }) - ).resolves.toBeFalsy(); - }); - it('cancel if no react deps', async () => { await expect( check({ @@ -61,9 +43,7 @@ describe('early exits', () => { it('cancel if react renderer', async () => { await expect( check({ - packageManagerContent: { - dependencies: { react: '16.0.0' }, - }, + packageManagerContent: { react: '16.0.0' }, main: { stories: [], framework: '@storybook/react-vite', @@ -73,9 +53,7 @@ describe('early exits', () => { await expect( check({ - packageManagerContent: { - dependencies: { react: '16.0.0' }, - }, + packageManagerContent: { react: '16.0.0' }, main: { stories: [], framework: '@storybook/nextjs', @@ -85,9 +63,7 @@ describe('early exits', () => { await expect( check({ - packageManagerContent: { - dependencies: { react: '16.0.0' }, - }, + packageManagerContent: { react: '16.0.0' }, main: { stories: [], framework: { name: '@storybook/react-webpack5' }, @@ -101,9 +77,7 @@ describe('prompts', () => { it('simple', async () => { await expect( check({ - packageManagerContent: { - dependencies: { react: '16.0.0' }, - }, + packageManagerContent: { react: '16.0.0' }, main: { stories: ['*.stories.ts'], addons: [], @@ -115,9 +89,7 @@ describe('prompts', () => { it('detects addon docs', async () => { await expect( check({ - packageManagerContent: { - dependencies: { react: '16.0.0' }, - }, + packageManagerContent: { react: '16.0.0' }, main: { stories: ['*.stories.ts'], addons: ['@storybook/addon-docs'], @@ -129,9 +101,7 @@ describe('prompts', () => { it('detects addon essentials', async () => { await expect( check({ - packageManagerContent: { - dependencies: { react: '16.0.0' }, - }, + packageManagerContent: { react: '16.0.0' }, main: { stories: ['*.stories.ts'], addons: ['@storybook/addon-docs', '@storybook/addon-essentials'], @@ -145,9 +115,7 @@ describe('prompts', () => { glob.mockImplementationOnce(() => ['*.stories.mdx']); await expect( check({ - packageManagerContent: { - dependencies: { react: '16.0.0' }, - }, + packageManagerContent: { react: '16.0.0' }, main: { stories: ['*.stories.ts'], addons: ['@storybook/addon-docs', '@storybook/addon-essentials'], diff --git a/code/lib/cli/src/automigrate/fixes/prompt-remove-react.ts b/code/lib/cli/src/automigrate/fixes/prompt-remove-react.ts index a7586a03ff1..65463ec015e 100644 --- a/code/lib/cli/src/automigrate/fixes/prompt-remove-react.ts +++ b/code/lib/cli/src/automigrate/fixes/prompt-remove-react.ts @@ -1,31 +1,28 @@ import dedent from 'ts-dedent'; -import semver from 'semver'; import { getFrameworkPackageName } from '../helpers/mainConfigFile'; import type { Fix } from '../types'; +// This fix is only relevant for projects using Storybook 8.0.0-alpha.4 or later +const minimumStorybookVersion = '8.0.0-alpha.4'; + export const removeReactDependency: Fix<{}> = { id: 'remove-react-dependency', promptType: 'manual', + versionRange: [`^7 || <${minimumStorybookVersion}`, `>=${minimumStorybookVersion}`], + async check({ packageManager, mainConfig, storybookVersion }) { // when the user is using the react renderer, we should not prompt them to remove react const frameworkPackageName = getFrameworkPackageName(mainConfig); + if (frameworkPackageName?.includes('react') || frameworkPackageName?.includes('nextjs')) { return null; } // if the user has no dependency on react, we can skip this fix - const packageJson = await packageManager.retrievePackageJson(); - if ( - !packageJson?.dependencies?.['react'] && - !packageJson?.peerDependencies?.['react'] && - !packageJson?.devDependencies?.['react'] - ) { - return null; - } + const { react } = await packageManager.getAllDependencies(); - // do not prompt to remove react for older versions of storybook - if (!semver.gte(storybookVersion, '8.0.0')) { + if (!react) { return null; } @@ -36,7 +33,7 @@ export const removeReactDependency: Fix<{}> = { We detected that your project has a dependency for "react" that it might not need. Nothing breaks by having it, you can safely ignore this message, if you wish. - Storybook asked you to add "react" as a direct dependency in the past. + Storybook asked you to add "react" as a direct dependency in the past when upgrading from Storybook 6 to 7. However, since version 8.0, Storybook no longer requires you to provide "react" as a dependency. Some community addons might still wrongfully list "react" and "react-dom" as required peer dependencies, but since Storybook 7.6 it should not be needed in the majority of cases. diff --git a/code/lib/cli/src/automigrate/fixes/react-docgen.ts b/code/lib/cli/src/automigrate/fixes/react-docgen.ts index e21c73db357..ef89a24915f 100644 --- a/code/lib/cli/src/automigrate/fixes/react-docgen.ts +++ b/code/lib/cli/src/automigrate/fixes/react-docgen.ts @@ -13,6 +13,8 @@ interface Options { export const reactDocgen: Fix = { id: 'react-docgen', + versionRange: ['<8.0.0-alpha.1', '>=8.0.0-alpha.1'], + async check({ mainConfig }) { // @ts-expect-error assume react const { reactDocgenTypescriptOptions } = mainConfig.typescript || {}; @@ -25,7 +27,7 @@ export const reactDocgen: Fix = { You have "typescript.reactDocgenTypescriptOptions" configured in your main.js, but "typescript.reactDocgen" is unset. - In Storybook 8.0, we changed the default React docgen analysis from + Since Storybook 8.0, we changed the default React docgen analysis from "react-docgen-typescript" to "react-docgen". We recommend "react-docgen" for most projects, since it is dramatically faster. However, it doesn't handle all TypeScript constructs, and may generate different results diff --git a/code/lib/cli/src/automigrate/fixes/remove-argtypes-regex.ts b/code/lib/cli/src/automigrate/fixes/remove-argtypes-regex.ts index 6a517fc0be0..8f15da8fc01 100644 --- a/code/lib/cli/src/automigrate/fixes/remove-argtypes-regex.ts +++ b/code/lib/cli/src/automigrate/fixes/remove-argtypes-regex.ts @@ -9,6 +9,7 @@ import chalk from 'chalk'; export const removeArgtypesRegex: Fix<{ argTypesRegex: NodePath; previewConfigPath: string }> = { id: 'remove-argtypes-regex', promptType: 'manual', + versionRange: ['<8.0.0-alpha.0', '>=8.0.0-alpha.0'], async check({ previewConfigPath }) { if (!previewConfigPath) return null; @@ -59,7 +60,7 @@ export const removeArgtypesRegex: Fix<{ argTypesRegex: NodePath; previewConfigPa ${argTypesRegex.buildCodeFrameError(`${previewConfigPath}`).message} - In Storybook 8, we recommend removing this regex. + Since Storybook 8, we recommend removing this regex. Assign explicit spies with the ${chalk.cyan('fn')} function instead: ${formattedSnippet} @@ -75,7 +76,8 @@ export const removeArgtypesRegex: Fix<{ argTypesRegex: NodePath; previewConfigPa Make sure to assign an explicit ${chalk.cyan('fn')} to your args for those usages. - For more information please visit our migration guide: https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#implicit-actions-can-not-be-used-during-rendering-for-example-in-the-play-function + For more information please visit our docs: + https://storybook.js.org/docs/8.0/essentials/actions#via-storybooktest-fn-spy-function `; }, }; diff --git a/code/lib/cli/src/automigrate/fixes/remove-global-client-apis.ts b/code/lib/cli/src/automigrate/fixes/remove-global-client-apis.ts index 2f25e527709..64de15f9a4b 100644 --- a/code/lib/cli/src/automigrate/fixes/remove-global-client-apis.ts +++ b/code/lib/cli/src/automigrate/fixes/remove-global-client-apis.ts @@ -21,6 +21,8 @@ export const removedGlobalClientAPIs: Fix = { id: 'removedglobalclientapis', promptType: 'manual', + versionRange: ['<7', '>=7'], + async check({ previewConfigPath }) { if (previewConfigPath) { const contents = await readFile(previewConfigPath, 'utf8'); diff --git a/code/lib/cli/src/automigrate/fixes/remove-jest-testing-library.test.ts b/code/lib/cli/src/automigrate/fixes/remove-jest-testing-library.test.ts index 60a2c2a97a3..29f6b391566 100644 --- a/code/lib/cli/src/automigrate/fixes/remove-jest-testing-library.test.ts +++ b/code/lib/cli/src/automigrate/fixes/remove-jest-testing-library.test.ts @@ -51,7 +51,7 @@ it('should prompt to install the test package and run the codemod', async () => }); expect(await removeJestTestingLibrary.prompt(options!)).toMatchInlineSnapshot(` - Attention: We've detected that you're using the following packages which are known to be incompatible with Storybook 8: + Attention: We've detected that you're using the following packages which are known to be incompatible since Storybook 8: - @storybook/jest - @storybook/testing-library diff --git a/code/lib/cli/src/automigrate/fixes/remove-jest-testing-library.ts b/code/lib/cli/src/automigrate/fixes/remove-jest-testing-library.ts index ba238dd2740..82ebfb7315d 100644 --- a/code/lib/cli/src/automigrate/fixes/remove-jest-testing-library.ts +++ b/code/lib/cli/src/automigrate/fixes/remove-jest-testing-library.ts @@ -4,8 +4,9 @@ import type { Fix } from '../types'; export const removeJestTestingLibrary: Fix<{ incompatiblePackages: string[] }> = { id: 'remove-jest-testing-library', + versionRange: ['<8.0.0-alpha.0', '>=8.0.0-alpha.0'], promptType: 'manual', - async check({ mainConfig, packageManager }) { + async check({ packageManager }) { const deps = await packageManager.getAllDependencies(); const incompatiblePackages = Object.keys(deps).filter( @@ -17,7 +18,7 @@ export const removeJestTestingLibrary: Fix<{ incompatiblePackages: string[] }> = return dedent` ${chalk.bold( 'Attention' - )}: We've detected that you're using the following packages which are known to be incompatible with Storybook 8: + )}: We've detected that you're using the following packages which are known to be incompatible since Storybook 8: ${incompatiblePackages.map((name) => `- ${chalk.cyan(`${name}`)}`).join('\n')} diff --git a/code/lib/cli/src/automigrate/fixes/remove-legacymdx1.ts b/code/lib/cli/src/automigrate/fixes/remove-legacymdx1.ts index 57f90e8403a..4045fc800cb 100644 --- a/code/lib/cli/src/automigrate/fixes/remove-legacymdx1.ts +++ b/code/lib/cli/src/automigrate/fixes/remove-legacymdx1.ts @@ -18,6 +18,7 @@ interface RemoveLegacyMDX1Options { */ export const removeLegacyMDX1: Fix = { id: 'builder-vite', + versionRange: ['<8.0.0-alpha.0', '>=8.0.0-alpha.0'], async check({ mainConfig }) { if (mainConfig.features) { diff --git a/code/lib/cli/src/automigrate/fixes/sb-binary.test.ts b/code/lib/cli/src/automigrate/fixes/sb-binary.test.ts index b1f06e162c3..bb5250179a8 100644 --- a/code/lib/cli/src/automigrate/fixes/sb-binary.test.ts +++ b/code/lib/cli/src/automigrate/fixes/sb-binary.test.ts @@ -17,31 +17,6 @@ const checkStorybookBinary = async ({ }; describe('storybook-binary fix', () => { - describe('sb < 7.0', () => { - describe('does nothing', () => { - const packageManager = { - getPackageVersion: (packageName) => { - switch (packageName) { - case '@storybook/react': - return Promise.resolve('6.2.0'); - default: - return null; - } - }, - retrievePackageJson: () => Promise.resolve({}), - } as Partial; - - it('should no-op', async () => { - await expect( - checkStorybookBinary({ - packageManager, - storybookVersion: '6.2.0', - }) - ).resolves.toBeFalsy(); - }); - }); - }); - describe('sb >= 7.0', () => { it('should no-op in NX projects', async () => { const packageManager = { diff --git a/code/lib/cli/src/automigrate/fixes/sb-binary.ts b/code/lib/cli/src/automigrate/fixes/sb-binary.ts index 4c84cf987d0..53b583b8a58 100644 --- a/code/lib/cli/src/automigrate/fixes/sb-binary.ts +++ b/code/lib/cli/src/automigrate/fixes/sb-binary.ts @@ -1,6 +1,5 @@ import chalk from 'chalk'; import { dedent } from 'ts-dedent'; -import semver from 'semver'; import type { Fix } from '../types'; import { getStorybookVersionSpecifier } from '../../helpers'; import type { PackageJsonWithDepsAndDevDeps } from '@storybook/core-common'; @@ -24,6 +23,8 @@ const logger = console; export const sbBinary: Fix = { id: 'storybook-binary', + versionRange: ['<7', '>=7'], + async check({ packageManager, storybookVersion }) { const packageJson = await packageManager.retrievePackageJson(); @@ -32,7 +33,7 @@ export const sbBinary: Fix = { const storybookBinaryVersion = await packageManager.getPackageVersion('storybook'); // Nx provides their own binary, so we don't need to do anything - if (nrwlStorybookVersion || semver.lt(storybookVersion, '7.0.0')) { + if (nrwlStorybookVersion) { return null; } diff --git a/code/lib/cli/src/automigrate/fixes/sb-scripts.ts b/code/lib/cli/src/automigrate/fixes/sb-scripts.ts index 7ff111920e2..aad1796b9a3 100644 --- a/code/lib/cli/src/automigrate/fixes/sb-scripts.ts +++ b/code/lib/cli/src/automigrate/fixes/sb-scripts.ts @@ -78,6 +78,8 @@ export const getStorybookScripts = (allScripts: NonNullable = { id: 'sb-scripts', + versionRange: ['<7', '>=7'], + async check({ packageManager, storybookVersion }) { const packageJson = await packageManager.retrievePackageJson(); const { scripts = {} } = packageJson; diff --git a/code/lib/cli/src/automigrate/fixes/storyshots-migration.ts b/code/lib/cli/src/automigrate/fixes/storyshots-migration.ts index 6d132037c3e..8b51fead9f3 100644 --- a/code/lib/cli/src/automigrate/fixes/storyshots-migration.ts +++ b/code/lib/cli/src/automigrate/fixes/storyshots-migration.ts @@ -4,6 +4,7 @@ import type { Fix } from '../types'; export const storyshotsMigration: Fix = { id: 'storyshots-migration', + versionRange: ['<8.0.0-alpha.0', '>=8.0.0-alpha.0'], promptType: 'manual', async check({ mainConfig, packageManager }) { diff --git a/code/lib/cli/src/automigrate/fixes/vite-config-file.ts b/code/lib/cli/src/automigrate/fixes/vite-config-file.ts index 899c3807ed3..6325b71ad06 100644 --- a/code/lib/cli/src/automigrate/fixes/vite-config-file.ts +++ b/code/lib/cli/src/automigrate/fixes/vite-config-file.ts @@ -13,6 +13,8 @@ interface ViteConfigFileRunOptions { export const viteConfigFile = { id: 'viteConfigFile', + versionRange: ['<8.0.0-beta.3', '>=8.0.0-beta.3'], + async check({ mainConfig, packageManager }) { let isViteConfigFileFound = !!(await findUp([ 'vite.config.js', @@ -94,7 +96,7 @@ export const viteConfigFile = { prompt({ existed, plugins }) { if (existed) { return dedent` - Storybook 8.0.0 no longer ships with a Vite config build-in. + Since version 8.0.0, Storybook no longer ships with a Vite config build-in. We've detected you do have a Vite config, but you may be missing the following plugins in it. ${plugins.map((plugin) => ` - ${plugin}`).join('\n')} @@ -108,7 +110,7 @@ export const viteConfigFile = { `; } return dedent` - Storybook 8.0.0 no longer ships with a Vite config build-in. + Since version 8.0.0, Storybook no longer ships with a Vite config build-in. Please add a vite.config.js file to your project root. You can find more information on how to do this here: diff --git a/code/lib/cli/src/automigrate/fixes/vite4.ts b/code/lib/cli/src/automigrate/fixes/vite4.ts deleted file mode 100644 index f85cc4d57ce..00000000000 --- a/code/lib/cli/src/automigrate/fixes/vite4.ts +++ /dev/null @@ -1,43 +0,0 @@ -import chalk from 'chalk'; -import { dedent } from 'ts-dedent'; -import semver from 'semver'; -import type { Fix } from '../types'; - -const logger = console; - -interface Vite4RunOptions { - viteVersion: string | null; -} - -export const vite4 = { - id: 'vite4', - - async check({ packageManager }) { - const viteVersion = await packageManager.getPackageVersion('vite'); - - if (!viteVersion || semver.gt(viteVersion, '4.0.0')) { - return null; - } - - return { viteVersion }; - }, - - prompt({ viteVersion: viteVersion }) { - const viteFormatted = chalk.cyan(`${viteVersion}`); - - return dedent` - We've detected your version of Vite is outdated (${viteFormatted}). - - Storybook 8.0.0 will require Vite 4.0.0 or later. - Do you want us to upgrade Vite for you? - `; - }, - - async run({ packageManager, dryRun }) { - const deps = [`vite`]; - logger.info(`✅ Adding dependencies: ${deps}`); - if (!dryRun) { - await packageManager.addDependencies({ installAsDevDependencies: true }, deps); - } - }, -} satisfies Fix; diff --git a/code/lib/cli/src/automigrate/fixes/vue3.ts b/code/lib/cli/src/automigrate/fixes/vue3.ts index 84bde42d619..f641b30373d 100644 --- a/code/lib/cli/src/automigrate/fixes/vue3.ts +++ b/code/lib/cli/src/automigrate/fixes/vue3.ts @@ -19,6 +19,8 @@ interface Vue3RunOptions { export const vue3: Fix = { id: 'vue3', + versionRange: ['<7', '>=7'], + async check({ packageManager, mainConfig, storybookVersion }) { const vueVersion = await packageManager.getPackageVersion('vue'); diff --git a/code/lib/cli/src/automigrate/fixes/webpack5-compiler-setup.ts b/code/lib/cli/src/automigrate/fixes/webpack5-compiler-setup.ts index a22f8a55fdd..d899ee5df3d 100644 --- a/code/lib/cli/src/automigrate/fixes/webpack5-compiler-setup.ts +++ b/code/lib/cli/src/automigrate/fixes/webpack5-compiler-setup.ts @@ -28,7 +28,7 @@ type Options = { export const webpack5CompilerSetup = { id: 'webpack5-compiler-setup', - + versionRange: ['<8.0.0-alpha.9', '>=8.0.0-alpha.9'], promptType(result) { return result.isNextJs && !result.shouldRemoveSWCFlag ? 'notification' : 'auto'; }, diff --git a/code/lib/cli/src/automigrate/fixes/webpack5.ts b/code/lib/cli/src/automigrate/fixes/webpack5.ts index 8b54ee2d5a8..a2072be41eb 100644 --- a/code/lib/cli/src/automigrate/fixes/webpack5.ts +++ b/code/lib/cli/src/automigrate/fixes/webpack5.ts @@ -25,7 +25,9 @@ interface Webpack5RunOptions { export const webpack5 = { id: 'webpack5', - async check({ configDir, packageManager, mainConfig, storybookVersion }) { + versionRange: ['<7', '>=7'], + + async check({ packageManager, mainConfig, storybookVersion }) { const webpackVersion = await packageManager.getPackageVersion('webpack'); if ( diff --git a/code/lib/cli/src/automigrate/fixes/wrap-require.ts b/code/lib/cli/src/automigrate/fixes/wrap-require.ts index 3651eb2c57a..e241f05858a 100644 --- a/code/lib/cli/src/automigrate/fixes/wrap-require.ts +++ b/code/lib/cli/src/automigrate/fixes/wrap-require.ts @@ -22,6 +22,8 @@ interface WrapRequireRunOptions { export const wrapRequire: Fix = { id: 'wrap-require', + versionRange: ['<7.2.0-rc.0', '>=7.2.0-rc.0'], + async check({ packageManager, storybookVersion, mainConfigPath }) { const isStorybookInMonorepo = await packageManager.isStorybookInMonorepo(); const isPnp = await detectPnp(); diff --git a/code/lib/cli/src/automigrate/index.test.ts b/code/lib/cli/src/automigrate/index.test.ts new file mode 100644 index 00000000000..78a098e9c8d --- /dev/null +++ b/code/lib/cli/src/automigrate/index.test.ts @@ -0,0 +1,157 @@ +import { vi, it, expect, describe, beforeEach } from 'vitest'; +import { runFixes } from './index'; +import type { Fix } from './types'; +import type { JsPackageManager, PackageJsonWithDepsAndDevDeps } from '@storybook/core-common'; +import { afterEach } from 'node:test'; + +const check1 = vi.fn(); +const run1 = vi.fn(); +const retrievePackageJson = vi.fn(); +const getPackageVersion = vi.fn(); +const prompt1Message = 'prompt1Message'; + +vi.spyOn(console, 'error').mockImplementation(console.log); + +const fixes: Fix[] = [ + { + id: 'fix-1', + + versionRange: ['<7', '>=7'], + + async check(config) { + return check1(config); + }, + + prompt() { + return prompt1Message; + }, + + async run(result) { + run1(result); + }, + }, +]; + +const coreCommonMock = vi.hoisted(() => { + return { + loadMainConfig: vi.fn(), + }; +}); + +vi.mock('@storybook/core-common', async (importOriginal) => ({ + ...(await importOriginal()), + loadMainConfig: coreCommonMock.loadMainConfig, +})); + +const promptMocks = vi.hoisted(() => { + return { + default: vi.fn(), + }; +}); + +vi.mock('prompts', () => { + return { + default: promptMocks.default, + }; +}); + +class PackageManager implements Partial { + public async retrievePackageJson(): Promise { + return retrievePackageJson(); + } + + getPackageVersion(packageName: string, basePath?: string | undefined): Promise { + return getPackageVersion(packageName, basePath); + } +} + +const packageManager = new PackageManager() as any as JsPackageManager; + +const dryRun = false; +const yes = true; +const rendererPackage = 'storybook'; +const skipInstall = false; +const configDir = '/path/to/config'; +const mainConfigPath = '/path/to/mainConfig'; +const beforeVersion = '6.5.15'; +const isUpgrade = true; + +const runFixWrapper = async ({ + // eslint-disable-next-line @typescript-eslint/no-shadow + beforeVersion, + storybookVersion, +}: { + beforeVersion: string; + storybookVersion: string; +}) => { + return runFixes({ + fixes, + dryRun, + yes, + rendererPackage, + skipInstall, + configDir, + packageManager: packageManager, + mainConfigPath, + storybookVersion, + beforeVersion, + isUpgrade, + }); +}; + +describe('runFixes', () => { + beforeEach(() => { + retrievePackageJson.mockResolvedValue({ + depedencies: [], + devDepedencies: [], + }); + getPackageVersion.mockImplementation((packageName) => { + return beforeVersion; + }); + check1.mockResolvedValue({ some: 'result' }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should be unnecessary to run fix-1 from SB 6.5.15 to 6.5.16', async () => { + const { fixResults } = await runFixWrapper({ beforeVersion, storybookVersion: '6.5.16' }); + + // Assertions + expect(fixResults).toEqual({ + 'fix-1': 'unnecessary', + }); + expect(run1).not.toHaveBeenCalled(); + }); + + it('should be necessary to run fix-1 from SB 6.5.15 to 7.0.0', async () => { + promptMocks.default.mockResolvedValue({ shouldContinue: true }); + + const { fixResults } = await runFixWrapper({ beforeVersion, storybookVersion: '7.0.0' }); + + expect(fixResults).toEqual({ + 'fix-1': 'succeeded', + }); + expect(run1).toHaveBeenCalledWith({ + dryRun, + mainConfigPath, + packageManager, + result: { + some: 'result', + }, + skipInstall, + }); + }); + + it('should fail if an error is thrown', async () => { + check1.mockRejectedValue(new Error('check1 error')); + + const { fixResults } = await runFixWrapper({ beforeVersion, storybookVersion: '7.0.0' }); + + expect(fixResults).toEqual({ + 'fix-1': 'check_failed', + }); + expect(run1).not.toHaveBeenCalled(); + }); +}); diff --git a/code/lib/cli/src/automigrate/index.ts b/code/lib/cli/src/automigrate/index.ts index 1e78d57863c..92d21e2ec6c 100644 --- a/code/lib/cli/src/automigrate/index.ts +++ b/code/lib/cli/src/automigrate/index.ts @@ -5,6 +5,7 @@ import { createWriteStream, move, remove } from 'fs-extra'; import tempy from 'tempy'; import { join } from 'path'; import invariant from 'tiny-invariant'; +import semver from 'semver'; import { JsPackageManagerFactory, @@ -82,7 +83,15 @@ export const doAutomigrate = async (options: AutofixOptionsFromCLI) => { throw new Error('Could not determine main config path'); } - return automigrate({ ...options, packageManager, storybookVersion, mainConfigPath, configDir }); + return automigrate({ + ...options, + packageManager, + storybookVersion, + beforeVersion: storybookVersion, + mainConfigPath, + configDir, + isUpgrade: false, + }); }; export const automigrate = async ({ @@ -95,6 +104,7 @@ export const automigrate = async ({ configDir, mainConfigPath, storybookVersion, + beforeVersion, renderer: rendererPackage, skipInstall, hideMigrationSummary = false, @@ -128,6 +138,7 @@ export const automigrate = async ({ configDir, mainConfigPath, storybookVersion, + beforeVersion, dryRun, yes, }); @@ -171,6 +182,8 @@ export async function runFixes({ packageManager, mainConfigPath, storybookVersion, + beforeVersion, + isUpgrade, }: { fixes: Fix[]; yes?: boolean; @@ -181,6 +194,8 @@ export async function runFixes({ packageManager: JsPackageManager; mainConfigPath: string; storybookVersion: string; + beforeVersion: string; + isUpgrade?: boolean; }): Promise<{ preCheckFailure?: PreCheckFailure; fixResults: Record; @@ -199,15 +214,22 @@ export async function runFixes({ packageManager, }); - result = await f.check({ - packageManager, - configDir, - rendererPackage, - mainConfig, - storybookVersion, - previewConfigPath, - mainConfigPath, - }); + if ( + (isUpgrade && + semver.satisfies(beforeVersion, f.versionRange[0], { includePrerelease: true }) && + semver.satisfies(storybookVersion, f.versionRange[1], { includePrerelease: true })) || + !isUpgrade + ) { + result = await f.check({ + packageManager, + configDir, + rendererPackage, + mainConfig, + storybookVersion, + previewConfigPath, + mainConfigPath, + }); + } } catch (error) { logger.info(`⚠️ failed to check fix ${chalk.bold(f.id)}`); if (error instanceof Error) { diff --git a/code/lib/cli/src/automigrate/types.ts b/code/lib/cli/src/automigrate/types.ts index 510cdcda24c..36b4bac18c4 100644 --- a/code/lib/cli/src/automigrate/types.ts +++ b/code/lib/cli/src/automigrate/types.ts @@ -23,13 +23,19 @@ export interface RunOptions { * promptType defines how the user will be prompted to apply an automigration fix * - auto: the fix will be applied automatically * - manual: the user will be prompted to apply the fix - * - notification: the user will be notified about the some changes. A fix isn't required + * - notification: the user will be notified about some changes. A fix isn't required, though */ export type Prompt = 'auto' | 'manual' | 'notification'; export interface Fix { id: string; promptType?: Prompt | ((result: ResultType) => Promise | Prompt); + /** + * The from/to version range of Storybook that this fix applies to. The strings are semver ranges. + * The versionRange will only be checked if the automigration is part of an upgrade. + * If the automigration is not part of an upgrade but rather called via `automigrate` CLI, the check function should handle the version check. + */ + versionRange: [from: string, to: string]; check: (options: CheckOptions) => Promise; prompt: (result: ResultType) => string; run?: (options: RunOptions) => Promise; @@ -46,7 +52,15 @@ export enum PreCheckFailure { export interface AutofixOptions extends Omit { packageManager: JsPackageManager; mainConfigPath: string; + /** + * The version of Storybook before the migration. + */ + beforeVersion: string; storybookVersion: string; + /** + * Whether the migration is part of an upgrade. + */ + isUpgrade: boolean; } export interface AutofixOptionsFromCLI { fixId?: FixId; diff --git a/code/lib/cli/src/migrate.ts b/code/lib/cli/src/migrate.ts index 5e38507afd6..f3041ed4636 100644 --- a/code/lib/cli/src/migrate.ts +++ b/code/lib/cli/src/migrate.ts @@ -41,6 +41,8 @@ export async function migrate(migration: any, { glob, dryRun, list, rename, pars mainConfigPath, packageManager, storybookVersion, + beforeVersion: storybookVersion, + isUpgrade: false, }); await addStorybookBlocksPackage(); } diff --git a/code/lib/cli/src/upgrade.test.ts b/code/lib/cli/src/upgrade.test.ts index 14995146072..14bafd7cf5b 100644 --- a/code/lib/cli/src/upgrade.test.ts +++ b/code/lib/cli/src/upgrade.test.ts @@ -1,12 +1,11 @@ import { describe, it, expect, vi } from 'vitest'; +import * as sbcc from '@storybook/core-common'; import { UpgradeStorybookToLowerVersionError, UpgradeStorybookToSameVersionError, } from '@storybook/core-events/server-errors'; import { doUpgrade, getStorybookVersion } from './upgrade'; -import type * as sbcc from '@storybook/core-common'; - const findInstallationsMock = vi.fn>(); vi.mock('@storybook/telemetry'); @@ -66,7 +65,7 @@ describe('Upgrade errors', () => { }); await expect(doUpgrade({} as any)).rejects.toThrowError(UpgradeStorybookToLowerVersionError); - expect(findInstallationsMock).toHaveBeenCalledWith(['storybook', '@storybook/cli']); + expect(findInstallationsMock).toHaveBeenCalledWith(Object.keys(sbcc.versions)); }); it('should throw an error when upgrading to the same version number', async () => { findInstallationsMock.mockResolvedValue({ @@ -83,6 +82,6 @@ describe('Upgrade errors', () => { }); await expect(doUpgrade({} as any)).rejects.toThrowError(UpgradeStorybookToSameVersionError); - expect(findInstallationsMock).toHaveBeenCalledWith(['storybook', '@storybook/cli']); + expect(findInstallationsMock).toHaveBeenCalledWith(Object.keys(sbcc.versions)); }); }); diff --git a/code/lib/cli/src/upgrade.ts b/code/lib/cli/src/upgrade.ts index 5a19c85502b..179c7d806a2 100644 --- a/code/lib/cli/src/upgrade.ts +++ b/code/lib/cli/src/upgrade.ts @@ -17,7 +17,6 @@ import { isCorePackage, versions, getStorybookInfo, - getCoercedStorybookVersion, loadMainConfig, JsPackageManagerFactory, } from '@storybook/core-common'; @@ -41,15 +40,12 @@ export const getStorybookVersion = (line: string) => { }; const getInstalledStorybookVersion = async (packageManager: JsPackageManager) => { - const installations = await packageManager.findInstallations(['storybook', '@storybook/cli']); + const installations = await packageManager.findInstallations(Object.keys(versions)); if (!installations) { return; } - const cliVersion = installations.dependencies['@storybook/cli']?.[0].version; - if (cliVersion) { - return cliVersion; - } - return installations.dependencies['storybook']?.[0].version; + + return Object.entries(installations.dependencies)[0]?.[1]?.[0].version; }; const deprecatedPackages = [ @@ -145,11 +141,10 @@ export const doUpgrade = async ({ throw new UpgradeStorybookToSameVersionError({ beforeVersion }); } - const [latestVersion, packageJson, storybookVersion] = await Promise.all([ + const [latestVersion, packageJson] = await Promise.all([ // packageManager.latestVersion('@storybook/cli'), packageManager.retrievePackageJson(), - getCoercedStorybookVersion(packageManager), ]); const isOutdated = lt(currentVersion, latestVersion); @@ -192,7 +187,7 @@ export const doUpgrade = async ({ const mainConfig = await loadMainConfig({ configDir }); // GUARDS - if (!storybookVersion) { + if (!beforeVersion) { throw new UpgradeStorybookUnknownCurrentVersionError(); } @@ -256,7 +251,7 @@ export const doUpgrade = async ({ } // AUTOMIGRATIONS - if (!skipCheck && !results && mainConfigPath && storybookVersion) { + if (!skipCheck && !results && mainConfigPath) { checkVersionConsistency(); results = await automigrate({ dryRun, @@ -264,7 +259,9 @@ export const doUpgrade = async ({ packageManager, configDir, mainConfigPath, - storybookVersion, + beforeVersion, + storybookVersion: currentVersion, + isUpgrade: true, }); } diff --git a/code/lib/codemod/src/index.ts b/code/lib/codemod/src/index.ts index 0bc3f4d1b3d..67eb2079919 100644 --- a/code/lib/codemod/src/index.ts +++ b/code/lib/codemod/src/index.ts @@ -87,17 +87,17 @@ export async function runCodemod(codemod: any, { glob, logger, dryRun, rename, p shell: true, } ); - if (result.status === 1) { + + if (codemod === 'mdx-to-csf' && result.status === 1) { + logger.log( + 'The codemod was not able to transform the files mentioned above. We have renamed the files to .mdx.broken. Please check the files and rename them back to .mdx after you have either manually transformed them to mdx + csf or fixed the issues so that the codemod can transform them.' + ); + } else if (result.status === 1) { logger.log('Skipped renaming because of errors.'); return; } } - if (!renameParts && codemod === 'mdx-to-csf') { - renameParts = ['.stories.mdx', '.mdx']; - rename = '.stories.mdx:.mdx;'; - } - if (renameParts) { const [from, to] = renameParts; logger.log(`=> Renaming ${rename}: ${files.length} files`); diff --git a/code/lib/codemod/src/transforms/__tests__/mdx-to-csf.test.ts b/code/lib/codemod/src/transforms/__tests__/mdx-to-csf.test.ts index 1be65c569ed..36db68148a3 100644 --- a/code/lib/codemod/src/transforms/__tests__/mdx-to-csf.test.ts +++ b/code/lib/codemod/src/transforms/__tests__/mdx-to-csf.test.ts @@ -598,6 +598,50 @@ it('story child is identifier', async () => { `); }); +it('should replace ArgsTable by Controls', async () => { + const input = dedent` + import { ArgsTable } from '@storybook/addon-docs/blocks'; + import { Button } from './button'; + + Dummy Code + + + `; + + const mdx = await jscodeshift({ source: input, path: 'Foobar.stories.mdx' }); + + expect(mdx).toMatchInlineSnapshot(` + import { Controls } from '@storybook/blocks'; + import { Button } from './button'; + + Dummy Code + + + `); +}); + +it('should not create stories.js file if there are no components', async () => { + const input = dedent` + import { Meta } from '@storybook/addon-docs'; + + + + # Welcome to Storybook + `; + + const mdx = await jscodeshift({ source: input, path: 'Foobar.stories.mdx' }); + + expect(fs.writeFileSync).not.toHaveBeenCalled(); + + expect(mdx).toMatchInlineSnapshot(` + import { Meta } from '@storybook/blocks'; + + + + # Welcome to Storybook + `); +}); + it('nameToValidExport', () => { expect(nameToValidExport('1 starts with digit')).toMatchInlineSnapshot(`$1StartsWithDigit`); expect(nameToValidExport('name')).toMatchInlineSnapshot(`Name`); diff --git a/code/lib/codemod/src/transforms/mdx-to-csf.ts b/code/lib/codemod/src/transforms/mdx-to-csf.ts index 9c657c822e0..765331e40b2 100644 --- a/code/lib/codemod/src/transforms/mdx-to-csf.ts +++ b/code/lib/codemod/src/transforms/mdx-to-csf.ts @@ -24,6 +24,9 @@ import type { MdxFlowExpression } from 'mdast-util-mdx-expression'; const mdxProcessor = remark().use(remarkMdx) as ReturnType; +const renameList: { original: string; baseName: string }[] = []; +const brokenList: { original: string; baseName: string }[] = []; + export default async function jscodeshift(info: FileInfo) { const parsed = path.parse(info.path); @@ -37,18 +40,37 @@ export default async function jscodeshift(info: FileInfo) { baseName += '_'; } - const result = await transform(info, path.basename(baseName)); + try { + const { csf, mdx } = await transform(info, path.basename(baseName)); - const [mdx, csf] = result; + if (csf != null) { + fs.writeFileSync(`${baseName}.stories.js`, csf); + } - if (csf != null) { - fs.writeFileSync(`${baseName}.stories.js`, csf); + renameList.push({ original: info.path, baseName }); + + return mdx; + } catch (e) { + brokenList.push({ original: info.path, baseName }); + throw e; } - - return mdx; } -export async function transform(info: FileInfo, baseName: string): Promise<[string, string]> { +// The JSCodeshift CLI doesn't return a list of files that were transformed or skipped. +// This is a workaround to rename the files after the transformation, which we can remove after we switch from jscodeshift to another solution. +process.on('exit', () => { + renameList.forEach((file) => { + fs.renameSync(file.original, `${file.baseName}.mdx`); + }); + brokenList.forEach((file) => { + fs.renameSync(file.original, `${file.original}.broken`); + }); +}); + +export async function transform( + info: FileInfo, + baseName: string +): Promise<{ mdx: string; csf: string | null }> { const root = mdxProcessor.parse(info.source); const storyNamespaceName = nameToValidExport(`${baseName}Stories`); @@ -74,25 +96,42 @@ export async function transform(info: FileInfo, baseName: string): Promise<[stri node.value = node.value .replaceAll('@storybook/addon-docs/blocks', '@storybook/blocks') .replaceAll('@storybook/addon-docs', '@storybook/blocks'); + + if (node.value.includes('@storybook/blocks')) { + // @ts-ignore + const file: BabelFile = new babel.File( + { filename: 'info.path' }, + { code: node.value, ast: babelParse(node.value) } + ); + + file.path.traverse({ + ImportDeclaration(path) { + if (path.node.source.value === '@storybook/blocks') { + path.get('specifiers').forEach((specifier) => { + if (specifier.isImportSpecifier()) { + const imported = specifier.get('imported'); + if (imported.isIdentifier() && imported.node.name === 'ArgsTable') { + imported.node.name = 'Controls'; + } + } + }); + } + }, + }); + + node.value = recast.print(file.ast).code; + } }); const file = getEsmAst(root); visit(root, ['mdxJsxFlowElement', 'mdxJsxTextElement'], (node, index, parent) => { if (node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') { - if (is(node, { name: 'Meta' })) { - metaAttributes.push(...node.attributes); - node.attributes = [ - { - type: 'mdxJsxAttribute', - name: 'of', - value: { - type: 'mdxJsxAttributeValueExpression', - value: storyNamespaceName, - }, - }, - ]; + if (is(node, { name: 'ArgsTable' })) { + node.name = 'Controls'; + node.attributes = []; } + if (is(node, { name: 'Story' })) { const nameAttribute = node.attributes.find( (it) => it.type === 'mdxJsxAttribute' && it.name === 'name' @@ -167,21 +206,6 @@ export async function transform(info: FileInfo, baseName: string): Promise<[stri return undefined; }); - const metaProperties = metaAttributes.flatMap((attribute) => { - if (attribute.type === 'mdxJsxAttribute') { - if (typeof attribute.value === 'string') { - return [t.objectProperty(t.identifier(attribute.name), t.stringLiteral(attribute.value))]; - } - return [ - t.objectProperty( - t.identifier(attribute.name), - babelParseExpression(attribute.value?.value ?? '') as any as t.Expression - ), - ]; - } - return []; - }); - file.path.traverse({ // remove mdx imports from csf ImportDeclaration(path) { @@ -196,11 +220,49 @@ export async function transform(info: FileInfo, baseName: string): Promise<[stri }, }); - if (storiesMap.size === 0 && metaAttributes.length === 0) { + if (storiesMap.size === 0) { // A CSF file must have at least one story, so skip migrating if this is the case. - return [mdxProcessor.stringify(root), '']; + return { + csf: null, + mdx: mdxProcessor.stringify(root), + }; } + // Rewrites the Meta tag to use the new story namespace + visit(root, ['mdxJsxFlowElement', 'mdxJsxTextElement'], (node, index, parent) => { + if ( + (node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') && + is(node, { name: 'Meta' }) + ) { + metaAttributes.push(...node.attributes); + node.attributes = [ + { + type: 'mdxJsxAttribute', + name: 'of', + value: { + type: 'mdxJsxAttributeValueExpression', + value: storyNamespaceName, + }, + }, + ]; + } + }); + + const metaProperties = metaAttributes.flatMap((attribute) => { + if (attribute.type === 'mdxJsxAttribute') { + if (typeof attribute.value === 'string') { + return [t.objectProperty(t.identifier(attribute.name), t.stringLiteral(attribute.value))]; + } + return [ + t.objectProperty( + t.identifier(attribute.name), + babelParseExpression(attribute.value?.value ?? '') as any as t.Expression + ), + ]; + } + return []; + }); + addStoriesImport(root, baseName, storyNamespaceName); const newStatements: t.Statement[] = [ @@ -297,7 +359,10 @@ export async function transform(info: FileInfo, baseName: string): Promise<[stri filepath: path, }); - return [newMdx, output]; + return { + csf: output, + mdx: newMdx, + }; } function getEsmAst(root: ReturnType) { diff --git a/code/lib/core-common/src/js-package-manager/NPMProxy.test.ts b/code/lib/core-common/src/js-package-manager/NPMProxy.test.ts index bd5a4372bcc..1445b1be2b8 100644 --- a/code/lib/core-common/src/js-package-manager/NPMProxy.test.ts +++ b/code/lib/core-common/src/js-package-manager/NPMProxy.test.ts @@ -375,7 +375,7 @@ describe('NPM Proxy', () => { } `); - const installations = await npmProxy.findInstallations(); + const installations = await npmProxy.findInstallations(['@storybook/*']); expect(installations).toMatchInlineSnapshot(` { diff --git a/code/lib/core-common/src/js-package-manager/NPMProxy.ts b/code/lib/core-common/src/js-package-manager/NPMProxy.ts index 62d8be2fec5..8e996861f32 100644 --- a/code/lib/core-common/src/js-package-manager/NPMProxy.ts +++ b/code/lib/core-common/src/js-package-manager/NPMProxy.ts @@ -130,7 +130,7 @@ export class NPMProxy extends JsPackageManager { }); } - public async findInstallations() { + public async findInstallations(pattern: string[]) { const exec = async ({ depth }: { depth: number }) => { const pipeToNull = platform() === 'win32' ? '2>NUL' : '2>/dev/null'; return this.executeCommand({ @@ -146,7 +146,7 @@ export class NPMProxy extends JsPackageManager { const commandResult = await exec({ depth: 99 }); const parsedOutput = JSON.parse(commandResult); - return this.mapDependencies(parsedOutput); + return this.mapDependencies(parsedOutput, pattern); } catch (e) { // when --depth is higher than 0, npm can return a non-zero exit code // in case the user's project has peer dependency issues. So we try again with no depth @@ -154,7 +154,7 @@ export class NPMProxy extends JsPackageManager { const commandResult = await exec({ depth: 0 }); const parsedOutput = JSON.parse(commandResult); - return this.mapDependencies(parsedOutput); + return this.mapDependencies(parsedOutput, pattern); } catch (err) { logger.warn(`An issue occurred while trying to find dependencies metadata using npm.`); return undefined; @@ -245,13 +245,21 @@ export class NPMProxy extends JsPackageManager { } } - protected mapDependencies(input: NpmListOutput): InstallationMetadata { + /** + * + * @param input The output of `npm ls --json` + * @param pattern A list of package names to filter the result. * can be used as a placeholder + */ + protected mapDependencies(input: NpmListOutput, pattern: string[]): InstallationMetadata { const acc: Record = {}; const existingVersions: Record = {}; const duplicatedDependencies: Record = {}; const recurse = ([name, packageInfo]: [string, NpmDependency]): void => { - if (!name || !name.includes('storybook')) return; + // transform pattern into regex where `*` is replaced with `.*` + if (!name || !pattern.some((p) => new RegExp(`^${p.replace(/\*/g, '.*')}$`).test(name))) { + return; + } const value = { version: packageInfo.version, diff --git a/code/lib/core-common/src/js-package-manager/PNPMProxy.ts b/code/lib/core-common/src/js-package-manager/PNPMProxy.ts index 7e7f0279e22..a2f8baf6206 100644 --- a/code/lib/core-common/src/js-package-manager/PNPMProxy.ts +++ b/code/lib/core-common/src/js-package-manager/PNPMProxy.ts @@ -107,7 +107,7 @@ export class PNPMProxy extends JsPackageManager { try { const parsedOutput = JSON.parse(commandResult); - return this.mapDependencies(parsedOutput); + return this.mapDependencies(parsedOutput, pattern); } catch (e) { return undefined; } @@ -241,7 +241,7 @@ export class PNPMProxy extends JsPackageManager { } } - protected mapDependencies(input: PnpmListOutput): InstallationMetadata { + protected mapDependencies(input: PnpmListOutput, pattern: string[]): InstallationMetadata { const acc: Record = {}; const existingVersions: Record = {}; const duplicatedDependencies: Record = {}; @@ -252,7 +252,10 @@ export class PNPMProxy extends JsPackageManager { }, {} as PnpmDependencies); const recurse = ([name, packageInfo]: [string, PnpmDependency]): void => { - if (!name || !name.includes('storybook')) return; + // transform pattern into regex where `*` is replaced with `.*` + if (!name || !pattern.some((p) => new RegExp(`^${p.replace(/\*/g, '.*')}$`).test(name))) { + return; + } const value = { version: packageInfo.version, diff --git a/code/lib/core-common/src/js-package-manager/Yarn1Proxy.ts b/code/lib/core-common/src/js-package-manager/Yarn1Proxy.ts index 2018e8f8ddf..5980b26accc 100644 --- a/code/lib/core-common/src/js-package-manager/Yarn1Proxy.ts +++ b/code/lib/core-common/src/js-package-manager/Yarn1Proxy.ts @@ -92,7 +92,7 @@ export class Yarn1Proxy extends JsPackageManager { try { const parsedOutput = JSON.parse(commandResult); - return this.mapDependencies(parsedOutput); + return this.mapDependencies(parsedOutput, pattern); } catch (e) { return undefined; } @@ -179,7 +179,7 @@ export class Yarn1Proxy extends JsPackageManager { } } - protected mapDependencies(input: Yarn1ListOutput): InstallationMetadata { + protected mapDependencies(input: Yarn1ListOutput, pattern: string[]): InstallationMetadata { if (input.type === 'tree') { const { trees } = input.data; const acc: Record = {}; @@ -189,7 +189,10 @@ export class Yarn1Proxy extends JsPackageManager { const recurse = (tree: (typeof trees)[0]) => { const { children } = tree; const { name, value } = parsePackageData(tree.name); - if (!name || !name.includes('storybook')) return; + if (!name || !pattern.some((p) => new RegExp(`^${p.replace(/\*/g, '.*')}$`).test(name))) { + return; + } + if (!existingVersions[name]?.includes(value.version)) { if (acc[name]) { acc[name].push(value); diff --git a/code/lib/core-common/src/js-package-manager/Yarn2Proxy.ts b/code/lib/core-common/src/js-package-manager/Yarn2Proxy.ts index 4f76868d395..09f535e2dfa 100644 --- a/code/lib/core-common/src/js-package-manager/Yarn2Proxy.ts +++ b/code/lib/core-common/src/js-package-manager/Yarn2Proxy.ts @@ -106,20 +106,14 @@ export class Yarn2Proxy extends JsPackageManager { public async findInstallations(pattern: string[]) { const commandResult = await this.executeCommand({ command: 'yarn', - args: [ - 'info', - '--name-only', - '--recursive', - pattern.map((p) => `"${p}"`).join(' '), - `"${pattern}"`, - ], + args: ['info', '--name-only', '--recursive', ...pattern], env: { FORCE_COLOR: 'false', }, }); try { - return this.mapDependencies(commandResult); + return this.mapDependencies(commandResult, pattern); } catch (e) { return undefined; } @@ -252,14 +246,17 @@ export class Yarn2Proxy extends JsPackageManager { } } - protected mapDependencies(input: string): InstallationMetadata { + protected mapDependencies(input: string, pattern: string[]): InstallationMetadata { const lines = input.split('\n'); const acc: Record = {}; const existingVersions: Record = {}; const duplicatedDependencies: Record = {}; lines.forEach((packageName) => { - if (!packageName || !packageName.includes('storybook')) { + if ( + !packageName || + !pattern.some((p) => new RegExp(`${p.replace(/\*/g, '.*')}`).test(packageName)) + ) { return; } diff --git a/code/lib/manager-api/src/index.tsx b/code/lib/manager-api/src/index.tsx index 24e288782ad..fbb8b74a642 100644 --- a/code/lib/manager-api/src/index.tsx +++ b/code/lib/manager-api/src/index.tsx @@ -517,11 +517,8 @@ export function useArgTypes(): ArgTypes { export { addons } from './lib/addons'; -/** - * We need to rename this so it's not compiled to a straight re-export - * Our globalization plugin can't handle an import and export of the same name in different lines - * @deprecated - */ +// We need to rename this so it's not compiled to a straight re-export +// Our globalization plugin can't handle an import and export of the same name in different lines const typesX = types; export { typesX as types }; diff --git a/code/lib/preview-api/src/modules/preview-web/docs-context/DocsContext.test.ts b/code/lib/preview-api/src/modules/preview-web/docs-context/DocsContext.test.ts index 22a3aacc8a4..5321f4bc697 100644 --- a/code/lib/preview-api/src/modules/preview-web/docs-context/DocsContext.test.ts +++ b/code/lib/preview-api/src/modules/preview-web/docs-context/DocsContext.test.ts @@ -33,6 +33,52 @@ describe('referenceCSFFile', () => { }); }); +describe('attachCSFFile', () => { + const firstCsfParts = csfFileParts('first-meta--first-story', 'first-meta'); + const secondCsfParts = csfFileParts('second-meta--second-story', 'second-meta'); + const store = { + componentStoriesFromCSFFile: ({ csfFile }: { csfFile: CSFFile }) => + csfFile === firstCsfParts.csfFile ? [firstCsfParts.story] : [secondCsfParts.story], + } as unknown as StoryStore; + + it('attaches multiple CSF files', () => { + // Arrange - create a context with both CSF files + const context = new DocsContext(channel, store, renderStoryToElement, [ + firstCsfParts.csfFile, + secondCsfParts.csfFile, + ]); + + // Act - attach the first CSF file + context.attachCSFFile(firstCsfParts.csfFile); + + // Assert - the first story is now the primary story and the only component story + expect(context.storyById()).toEqual(firstCsfParts.story); + expect(context.componentStories()).toEqual([firstCsfParts.story]); + + // Assert - stories from both CSF files are available + expect(context.componentStoriesFromCSFFile(firstCsfParts.csfFile)).toEqual([ + firstCsfParts.story, + ]); + expect(context.componentStoriesFromCSFFile(secondCsfParts.csfFile)).toEqual([ + secondCsfParts.story, + ]); + + // Act - attach the second CSF file + context.attachCSFFile(secondCsfParts.csfFile); + + // Assert - the first story is still the primary story but both stories are available + expect(context.storyById()).toEqual(firstCsfParts.story); + expect(context.componentStories()).toEqual([firstCsfParts.story, secondCsfParts.story]); + + // Act - attach the second CSF file again + context.attachCSFFile(secondCsfParts.csfFile); + + // Assert - still only two stories are available + expect(context.storyById()).toEqual(firstCsfParts.story); + expect(context.componentStories()).toEqual([firstCsfParts.story, secondCsfParts.story]); + }); +}); + describe('resolveOf', () => { const { story, csfFile, storyExport, metaExport, moduleExports, component } = csfFileParts(); diff --git a/code/lib/preview-api/src/modules/preview-web/docs-context/DocsContext.ts b/code/lib/preview-api/src/modules/preview-web/docs-context/DocsContext.ts index da41240756d..b22ad4afe44 100644 --- a/code/lib/preview-api/src/modules/preview-web/docs-context/DocsContext.ts +++ b/code/lib/preview-api/src/modules/preview-web/docs-context/DocsContext.ts @@ -27,7 +27,7 @@ export class DocsContext implements DocsContextProps private nameToStoryId: Map; - private attachedCSFFile?: CSFFile; + private attachedCSFFiles: Set>; private primaryStory?: PreparedStory; @@ -38,11 +38,12 @@ export class DocsContext implements DocsContextProps /** The CSF files known (via the index) to be refererenced by this docs file */ csfFiles: CSFFile[] ) { + this.componentStoriesValue = []; this.storyIdToCSFFile = new Map(); this.exportToStory = new Map(); this.exportsToCSFFile = new Map(); this.nameToStoryId = new Map(); - this.componentStoriesValue = []; + this.attachedCSFFiles = new Set(); csfFiles.forEach((csfFile, index) => { this.referenceCSFFile(csfFile); @@ -71,10 +72,15 @@ export class DocsContext implements DocsContextProps if (!this.exportsToCSFFile.has(csfFile.moduleExports)) { throw new Error('Cannot attach a CSF file that has not been referenced'); } + if (this.attachedCSFFiles.has(csfFile)) { + // this CSF file is already attached, don't do anything + return; + } - this.attachedCSFFile = csfFile; + this.attachedCSFFiles.add(csfFile); const stories = this.store.componentStoriesFromCSFFile({ csfFile }); + stories.forEach((story) => { this.nameToStoryId.set(story.name, story.id); this.componentStoriesValue.push(story); @@ -115,15 +121,18 @@ export class DocsContext implements DocsContextProps return { type: 'story', story: this.primaryStory } as TResolvedExport; } - if (!this.attachedCSFFile) + if (this.attachedCSFFiles.size === 0) throw new Error( `No CSF file attached to this docs file, did you forget to use ?` ); - if (moduleExportType === 'meta') - return { type: 'meta', csfFile: this.attachedCSFFile } as TResolvedExport; + const firstAttachedCSFFile = Array.from(this.attachedCSFFiles)[0]; - const { component } = this.attachedCSFFile.meta; + if (moduleExportType === 'meta') { + return { type: 'meta', csfFile: firstAttachedCSFFile } as TResolvedExport; + } + + const { component } = firstAttachedCSFFile.meta; if (!component) throw new Error( `Attached CSF file does not defined a component, did you forget to export one?` diff --git a/code/lib/preview-api/src/modules/preview-web/docs-context/test-utils.ts b/code/lib/preview-api/src/modules/preview-web/docs-context/test-utils.ts index 4be2710c8f1..5de026e902b 100644 --- a/code/lib/preview-api/src/modules/preview-web/docs-context/test-utils.ts +++ b/code/lib/preview-api/src/modules/preview-web/docs-context/test-utils.ts @@ -1,6 +1,6 @@ import type { CSFFile, PreparedStory } from '@storybook/types'; -export function csfFileParts() { +export function csfFileParts(storyId = 'meta--story', metaId = 'meta') { // These compose the raw exports of the CSF file const component = {}; const metaExport = { component }; @@ -9,13 +9,13 @@ export function csfFileParts() { // This is the prepared story + CSF file after SB has processed them const storyAnnotations = { - id: 'meta--story', + id: storyId, moduleExport: storyExport, } as CSFFile['stories'][string]; - const story = { id: 'meta--story', moduleExport: storyExport } as PreparedStory; - const meta = { id: 'meta', title: 'Meta', component, moduleExports } as CSFFile['meta']; + const story = { id: storyId, moduleExport: storyExport } as PreparedStory; + const meta = { id: metaId, title: 'Meta', component, moduleExports } as CSFFile['meta']; const csfFile = { - stories: { 'meta--story': storyAnnotations }, + stories: { [storyId]: storyAnnotations }, meta, moduleExports, } as CSFFile; diff --git a/code/lib/types/src/modules/addons.ts b/code/lib/types/src/modules/addons.ts index fe4fa8551cf..c1325e3f07e 100644 --- a/code/lib/types/src/modules/addons.ts +++ b/code/lib/types/src/modules/addons.ts @@ -358,7 +358,12 @@ export interface Addon_BaseType { * This is called as a function, so if you want to use hooks, * your function needs to return a JSX.Element within which components are rendered */ - render: (renderOptions: Partial) => ReactElement | null; + render: (props: Partial) => ReturnType>>; + // TODO: for Storybook 9 I'd like to change this to be: + // render: FC>; + // This would bring it in line with how every other addon is set up. + // We'd need to change how the render function is called in the manager: + // https://github.com/storybookjs/storybook/blob/4e6fc0dde0842841d99cb3cf5148ca293a950301/code/ui/manager/src/components/preview/Preview.tsx#L105 /** * @unstable */ diff --git a/code/renderers/react/src/docs/jsxDecorator.tsx b/code/renderers/react/src/docs/jsxDecorator.tsx index e43702daf02..3395a386055 100644 --- a/code/renderers/react/src/docs/jsxDecorator.tsx +++ b/code/renderers/react/src/docs/jsxDecorator.tsx @@ -130,7 +130,7 @@ export const renderJsx = (code: React.ReactElement, options: JSXOptions) => { return string; }).join('\n'); - return result.replace(/function\s+noRefCheck\(\)\s+\{\}/g, '() => {}'); + return result.replace(/function\s+noRefCheck\(\)\s*\{\}/g, '() => {}'); }; const defaultOpts = { diff --git a/code/renderers/svelte/template/components/Html.svelte b/code/renderers/svelte/template/components/Html.svelte index e341acf7328..bc155de5d4b 100644 --- a/code/renderers/svelte/template/components/Html.svelte +++ b/code/renderers/svelte/template/components/Html.svelte @@ -5,4 +5,4 @@ export let content = ''; -
{@html content}>
+
{@html content}
diff --git a/code/ui/blocks/src/blocks/Controls.stories.tsx b/code/ui/blocks/src/blocks/Controls.stories.tsx index 9d32d9fe12f..598485dd93a 100644 --- a/code/ui/blocks/src/blocks/Controls.stories.tsx +++ b/code/ui/blocks/src/blocks/Controls.stories.tsx @@ -6,17 +6,19 @@ import * as ExampleStories from '../examples/ControlsParameters.stories'; import * as SubcomponentsExampleStories from '../examples/ControlsWithSubcomponentsParameters.stories'; import { within } from '@storybook/test'; import type { PlayFunctionContext } from '@storybook/csf'; +import * as EmptyArgTypesStories from '../examples/EmptyArgTypes.stories'; -const meta: Meta = { +const meta = { component: Controls, parameters: { relativeCsfPaths: [ '../examples/ControlsParameters.stories', + '../examples/EmptyArgTypes.stories', '../examples/ControlsWithSubcomponentsParameters.stories', ], docsStyles: true, }, -}; +} satisfies Meta; export default meta; type Story = StoryObj; @@ -142,3 +144,12 @@ export const SubcomponentsSortProp: Story = { sort: 'alpha', }, }; + +/** + * When a story is defined without any argTypes or args, the Docs UI should not display the control component. + */ +export const EmptyArgTypes: Story = { + args: { + of: EmptyArgTypesStories.Default, + }, +}; diff --git a/code/ui/blocks/src/blocks/Controls.tsx b/code/ui/blocks/src/blocks/Controls.tsx index 2adef888e66..f47194033f1 100644 --- a/code/ui/blocks/src/blocks/Controls.tsx +++ b/code/ui/blocks/src/blocks/Controls.tsx @@ -59,6 +59,9 @@ export const Controls: FC = (props) => { const hasSubcomponents = Boolean(subcomponents) && Object.keys(subcomponents).length > 0; if (!hasSubcomponents) { + if (!(Object.keys(filteredArgTypes).length > 0 || Object.keys(args).length > 0)) { + return null; + } return (
I am a story without args or argTypes
, + parameters: { chromatic: { disableSnapshot: true } }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** + * There are no argTypes or args, so this story won't show any controls in the docs page. + * In the control addon it will show a UI how to set up controls. + */ +export const Default: Story = {}; diff --git a/code/ui/manager/src/components/layout/LayoutProvider.tsx b/code/ui/manager/src/components/layout/LayoutProvider.tsx index ae088f6358d..81c342e0546 100644 --- a/code/ui/manager/src/components/layout/LayoutProvider.tsx +++ b/code/ui/manager/src/components/layout/LayoutProvider.tsx @@ -1,5 +1,5 @@ import type { FC, PropsWithChildren } from 'react'; -import React, { createContext, useContext, useState } from 'react'; +import React, { createContext, useContext, useMemo, useState } from 'react'; import { useMediaQuery } from '../hooks/useMedia'; import { BREAKPOINT } from '../../constants'; @@ -32,22 +32,29 @@ export const LayoutProvider: FC = ({ children }) => { const isDesktop = useMediaQuery(`(min-width: ${BREAKPOINT}px)`); const isMobile = !isDesktop; - return ( - - {children} - + const contextValue = useMemo( + () => ({ + isMobileMenuOpen, + setMobileMenuOpen, + isMobileAboutOpen, + setMobileAboutOpen, + isMobilePanelOpen, + setMobilePanelOpen, + isDesktop, + isMobile, + }), + [ + isMobileMenuOpen, + setMobileMenuOpen, + isMobileAboutOpen, + setMobileAboutOpen, + isMobilePanelOpen, + setMobilePanelOpen, + isDesktop, + isMobile, + ] ); + return {children}; }; export const useLayout = () => useContext(LayoutContext); diff --git a/code/ui/manager/src/components/sidebar/Search.tsx b/code/ui/manager/src/components/sidebar/Search.tsx index cbbecc8264a..0df164a2d35 100644 --- a/code/ui/manager/src/components/sidebar/Search.tsx +++ b/code/ui/manager/src/components/sidebar/Search.tsx @@ -5,7 +5,7 @@ import Downshift from 'downshift'; import type { FuseOptions } from 'fuse.js'; import Fuse from 'fuse.js'; import { global } from '@storybook/global'; -import React, { useMemo, useRef, useState, useCallback } from 'react'; +import React, { useRef, useState, useCallback } from 'react'; import { CloseIcon, SearchIcon } from '@storybook/icons'; import { DEFAULT_REF_ID } from './Sidebar'; import type { @@ -176,8 +176,8 @@ export const Search = React.memo<{ [api, inputRef, showAllComponents, DEFAULT_REF_ID] ); - const list: SearchItem[] = useMemo(() => { - return dataset.entries.reduce((acc, [refId, { index, status }]) => { + const makeFuse = useCallback(() => { + const list = dataset.entries.reduce((acc, [refId, { index, status }]) => { const groupStatus = getGroupStatus(index || {}, status); if (index) { @@ -196,12 +196,12 @@ export const Search = React.memo<{ } return acc; }, []); + return new Fuse(list, options); }, [dataset]); - const fuse = useMemo(() => new Fuse(list, options), [list]); - const getResults = useCallback( (input: string) => { + const fuse = makeFuse(); if (!input) return []; let results: DownshiftItem[] = []; @@ -229,7 +229,7 @@ export const Search = React.memo<{ return results; }, - [allComponents, fuse] + [allComponents, makeFuse] ); const stateReducer = useCallback( diff --git a/code/ui/manager/src/components/sidebar/Tree.tsx b/code/ui/manager/src/components/sidebar/Tree.tsx index d1a566bddf4..42aa27ce694 100644 --- a/code/ui/manager/src/components/sidebar/Tree.tsx +++ b/code/ui/manager/src/components/sidebar/Tree.tsx @@ -481,55 +481,73 @@ export const Tree = React.memo<{ const groupStatus = useMemo(() => getGroupStatus(collapsedData, status), [collapsedData, status]); - return ( - 0}> - - {collapsedItems.map((itemId) => { - const item = collapsedData[itemId]; - const id = createId(itemId, refId); - - if (item.type === 'root') { - const descendants = expandableDescendants[item.id]; - const isFullyExpanded = descendants.every((d: string) => expanded[d]); - return ( - // @ts-expect-error (TODO) - - ); - } - - const isDisplayed = !item.parent || ancestry[itemId].every((a: string) => expanded[a]); - const color = groupStatus[itemId] ? statusMapping[groupStatus[itemId]][1] : null; + const treeItems = useMemo(() => { + return collapsedItems.map((itemId) => { + const item = collapsedData[itemId]; + const id = createId(itemId, refId); + if (item.type === 'root') { + const descendants = expandableDescendants[item.id]; + const isFullyExpanded = descendants.every((d: string) => expanded[d]); return ( - itemId === oid || itemId.startsWith(`${oid}-`))} - isDisplayed={isDisplayed} + isOrphan={false} + isDisplayed isSelected={selectedStoryId === itemId} isExpanded={!!expanded[itemId]} setExpanded={setExpanded} + isFullyExpanded={isFullyExpanded} + expandableDescendants={descendants} onSelectStoryId={onSelectStoryId} /> ); - })} + } + + const isDisplayed = !item.parent || ancestry[itemId].every((a: string) => expanded[a]); + const color = groupStatus[itemId] ? statusMapping[groupStatus[itemId]][1] : null; + + return ( + itemId === oid || itemId.startsWith(`${oid}-`))} + isDisplayed={isDisplayed} + isSelected={selectedStoryId === itemId} + isExpanded={!!expanded[itemId]} + setExpanded={setExpanded} + onSelectStoryId={onSelectStoryId} + /> + ); + }); + }, [ + ancestry, + api, + collapsedData, + collapsedItems, + docsMode, + expandableDescendants, + expanded, + groupStatus, + onSelectStoryId, + orphanIds, + refId, + selectedStoryId, + setExpanded, + status, + ]); + return ( + 0}> + + {treeItems} ); }); diff --git a/docs/migration-guide/index.md b/docs/migration-guide/index.md index c7ae22f060a..70510a941d8 100644 --- a/docs/migration-guide/index.md +++ b/docs/migration-guide/index.md @@ -122,6 +122,10 @@ Storybook now requires that MDX pages reference stories written in CSF, rather t You’ll also need to update your stories glob in `.storybook/main.js` to include the newly created .mdx and .stories.js files if it doesn’t already. +#### Known limitations + +- The codemod does not remove the extracted stories from the `.stories.mdx` files. You will need to do this manually. + **Note:** this migration supports the Storybook 6 ["CSF stories with MDX docs"](https://github.com/storybookjs/storybook/blob/6e19f0fe426d58f0f7981a42c3d0b0384fab49b1/code/addons/docs/docs/recipes.md#csf-stories-with-mdx-docs) recipe. ## Troubleshooting