From d43bef1aa3ac1c07ec43ded39c0532a8cf89050c Mon Sep 17 00:00:00 2001 From: Yann Braga Date: Fri, 12 May 2023 16:29:11 +0200 Subject: [PATCH 1/5] Merge pull request #22523 from storybookjs/feat/detach-automigrate-from-init CLI: Detach automigrate command from storybook init --- .../automigrate/fixes/eslint-plugin.test.ts | 1 + .../src/automigrate/fixes/eslint-plugin.ts | 37 ++----- .../src/automigrate/fixes/missing-babelrc.ts | 11 +- .../src/automigrate/helpers/eslintPlugin.ts | 99 +++++++++++++++++ .../src/automigrate/helpers/getEslintInfo.ts | 20 ---- code/lib/cli/src/babel-config.ts | 103 ++++++++++-------- .../cli/src/generators/REACT_SCRIPTS/index.ts | 3 +- code/lib/cli/src/generators/baseGenerator.ts | 81 ++++++++++---- code/lib/cli/src/generators/types.ts | 3 +- code/lib/cli/src/helpers.ts | 1 + .../js-package-manager/JsPackageManager.ts | 19 ---- 11 files changed, 232 insertions(+), 146 deletions(-) create mode 100644 code/lib/cli/src/automigrate/helpers/eslintPlugin.ts delete mode 100644 code/lib/cli/src/automigrate/helpers/getEslintInfo.ts diff --git a/code/lib/cli/src/automigrate/fixes/eslint-plugin.test.ts b/code/lib/cli/src/automigrate/fixes/eslint-plugin.test.ts index 78fdedec90b8..cb242c9b0626 100644 --- a/code/lib/cli/src/automigrate/fixes/eslint-plugin.test.ts +++ b/code/lib/cli/src/automigrate/fixes/eslint-plugin.test.ts @@ -52,6 +52,7 @@ describe('eslint-plugin fix', () => { await expect( checkEslint({ packageJson, + hasEslint: false, }) ).resolves.toBeFalsy(); }); diff --git a/code/lib/cli/src/automigrate/fixes/eslint-plugin.ts b/code/lib/cli/src/automigrate/fixes/eslint-plugin.ts index 9b461e97d1a7..a34f30e78340 100644 --- a/code/lib/cli/src/automigrate/fixes/eslint-plugin.ts +++ b/code/lib/cli/src/automigrate/fixes/eslint-plugin.ts @@ -1,10 +1,12 @@ import chalk from 'chalk'; import { dedent } from 'ts-dedent'; -import { readConfig, writeConfig } from '@storybook/csf-tools'; -import { readFile, readJson, writeJson } from 'fs-extra'; -import detectIndent from 'detect-indent'; -import { findEslintFile, SUPPORTED_ESLINT_EXTENSIONS } from '../helpers/getEslintInfo'; +import { + configureEslintPlugin, + extractEslintInfo, + findEslintFile, + SUPPORTED_ESLINT_EXTENSIONS, +} from '../helpers/eslintPlugin'; import type { Fix } from '../types'; @@ -25,12 +27,9 @@ export const eslintPlugin: Fix = { id: 'eslintPlugin', async check({ packageManager }) { - const allDependencies = await packageManager.getAllDependencies(); + const { hasEslint, isStorybookPluginInstalled } = await extractEslintInfo(packageManager); - const eslintPluginStorybook = allDependencies['eslint-plugin-storybook']; - const eslintDependency = allDependencies.eslint; - - if (eslintPluginStorybook || !eslintDependency) { + if (isStorybookPluginInstalled || !hasEslint) { return null; } @@ -82,26 +81,8 @@ export const eslintPlugin: Fix = { return; } - logger.info(`βœ… Adding Storybook plugin to ${eslintFile}`); if (!dryRun) { - if (eslintFile.endsWith('json')) { - const eslintConfig = (await readJson(eslintFile)) as { extends?: string[] }; - const existingConfigValue = Array.isArray(eslintConfig.extends) - ? eslintConfig.extends - : [eslintConfig.extends]; - eslintConfig.extends = [...(existingConfigValue || []), 'plugin:storybook/recommended']; - - const eslintFileContents = await readFile(eslintFile, 'utf8'); - const spaces = detectIndent(eslintFileContents).amount || 2; - await writeJson(eslintFile, eslintConfig, { spaces }); - } else { - const eslint = await readConfig(eslintFile); - const extendsConfig = eslint.getFieldValue(['extends']) || []; - const existingConfigValue = Array.isArray(extendsConfig) ? extendsConfig : [extendsConfig]; - eslint.setFieldValue(['extends'], [...existingConfigValue, 'plugin:storybook/recommended']); - - await writeConfig(eslint); - } + await configureEslintPlugin(eslintFile, packageManager); } }, }; diff --git a/code/lib/cli/src/automigrate/fixes/missing-babelrc.ts b/code/lib/cli/src/automigrate/fixes/missing-babelrc.ts index 2e13685fd8b4..1b403a6b0d4b 100644 --- a/code/lib/cli/src/automigrate/fixes/missing-babelrc.ts +++ b/code/lib/cli/src/automigrate/fixes/missing-babelrc.ts @@ -65,20 +65,19 @@ export const missingBabelRc: Fix = { If your project does not have a babel configuration file, we can generate one that's equivalent to the 6.x defaults for you. Keep in mind that this can affect your project if it uses babel, and you may need to make additional changes based on your projects needs. + We can create a ${chalk.blue( + '.babelrc.json' + )} file with some basic configuration and add any necessary package devDependencies. + ${chalk.bold( 'Note:' - )} This automatic setup doesn't work in a monorepo, see the babel documentation for how to setup babel manually: + )} After installing the necessary presets, if it does not work in a monorepo, see the babel documentation for reference: ${chalk.yellow('https://babeljs.io/docs')} - We can create a ${chalk.blue( - '.babelrc.json' - )} file with some basic configuration and add any necessary package devDependencies. - Please see the migration guide for more information: ${chalk.yellow( 'https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#babel-mode-v7-exclusively' )} - `; }, async run() { diff --git a/code/lib/cli/src/automigrate/helpers/eslintPlugin.ts b/code/lib/cli/src/automigrate/helpers/eslintPlugin.ts new file mode 100644 index 000000000000..3d31d1111498 --- /dev/null +++ b/code/lib/cli/src/automigrate/helpers/eslintPlugin.ts @@ -0,0 +1,99 @@ +import fse, { readFile, readJson, writeJson } from 'fs-extra'; + +import { dedent } from 'ts-dedent'; +import detectIndent from 'detect-indent'; +import { readConfig, writeConfig } from '@storybook/csf-tools'; +import prompts from 'prompts'; +import chalk from 'chalk'; +import type { JsPackageManager } from '../../js-package-manager'; +import { paddedLog } from '../../helpers'; + +export const SUPPORTED_ESLINT_EXTENSIONS = ['js', 'cjs', 'json']; +const UNSUPPORTED_ESLINT_EXTENSIONS = ['yaml', 'yml']; + +export const findEslintFile = () => { + const filePrefix = '.eslintrc'; + const unsupportedExtension = UNSUPPORTED_ESLINT_EXTENSIONS.find((ext: string) => + fse.existsSync(`${filePrefix}.${ext}`) + ); + + if (unsupportedExtension) { + throw new Error(unsupportedExtension); + } + + const extension = SUPPORTED_ESLINT_EXTENSIONS.find((ext: string) => + fse.existsSync(`${filePrefix}.${ext}`) + ); + return extension ? `${filePrefix}.${extension}` : null; +}; + +export async function extractEslintInfo(packageManager: JsPackageManager): Promise<{ + hasEslint: boolean; + isStorybookPluginInstalled: boolean; + eslintConfigFile: string | null; +}> { + const allDependencies = await packageManager.getAllDependencies(); + const packageJson = await packageManager.retrievePackageJson(); + let eslintConfigFile: string | null = null; + + try { + eslintConfigFile = findEslintFile(); + } catch (err) { + // + } + + const isStorybookPluginInstalled = !!allDependencies['eslint-plugin-storybook']; + const hasEslint = allDependencies.eslint || eslintConfigFile || packageJson.eslintConfig; + return { hasEslint, isStorybookPluginInstalled, eslintConfigFile }; +} + +export async function configureEslintPlugin(eslintFile: string, packageManager: JsPackageManager) { + if (eslintFile) { + paddedLog(`Configuring Storybook ESLint plugin at ${eslintFile}`); + if (eslintFile.endsWith('json')) { + const eslintConfig = (await readJson(eslintFile)) as { extends?: string[] }; + const existingConfigValue = Array.isArray(eslintConfig.extends) + ? eslintConfig.extends + : [eslintConfig.extends]; + eslintConfig.extends = [...(existingConfigValue || []), 'plugin:storybook/recommended']; + + const eslintFileContents = await readFile(eslintFile, 'utf8'); + const spaces = detectIndent(eslintFileContents).amount || 2; + await writeJson(eslintFile, eslintConfig, { spaces }); + } else { + const eslint = await readConfig(eslintFile); + const extendsConfig = eslint.getFieldValue(['extends']) || []; + const existingConfigValue = Array.isArray(extendsConfig) ? extendsConfig : [extendsConfig]; + eslint.setFieldValue(['extends'], [...existingConfigValue, 'plugin:storybook/recommended']); + + await writeConfig(eslint); + } + } else { + paddedLog(`Configuring eslint-plugin-storybook in your package.json`); + const packageJson = await packageManager.retrievePackageJson(); + await packageManager.writePackageJson({ + ...packageJson, + eslintConfig: { + ...packageJson.eslintConfig, + extends: [...(packageJson.eslintConfig?.extends || []), 'plugin:storybook/recommended'], + }, + }); + } +} + +export const suggestESLintPlugin = async (): Promise => { + const { shouldInstall } = await prompts({ + type: 'confirm', + name: 'shouldInstall', + message: dedent` + We have detected that you're using ESLint. Storybook provides a plugin that gives the best experience with Storybook and helps follow best practices: ${chalk.yellow( + 'https://github.com/storybookjs/eslint-plugin-storybook#readme' + )} + + Would you like to install it? + `, + initial: true, + }); + + return shouldInstall; +}; diff --git a/code/lib/cli/src/automigrate/helpers/getEslintInfo.ts b/code/lib/cli/src/automigrate/helpers/getEslintInfo.ts deleted file mode 100644 index 698e2c4bde7e..000000000000 --- a/code/lib/cli/src/automigrate/helpers/getEslintInfo.ts +++ /dev/null @@ -1,20 +0,0 @@ -import fse from 'fs-extra'; - -export const SUPPORTED_ESLINT_EXTENSIONS = ['js', 'cjs', 'json']; -const UNSUPPORTED_ESLINT_EXTENSIONS = ['yaml', 'yml']; - -export const findEslintFile = () => { - const filePrefix = '.eslintrc'; - const unsupportedExtension = UNSUPPORTED_ESLINT_EXTENSIONS.find((ext: string) => - fse.existsSync(`${filePrefix}.${ext}`) - ); - - if (unsupportedExtension) { - throw new Error(unsupportedExtension); - } - - const extension = SUPPORTED_ESLINT_EXTENSIONS.find((ext: string) => - fse.existsSync(`${filePrefix}.${ext}`) - ); - return extension ? `${filePrefix}.${extension}` : null; -}; diff --git a/code/lib/cli/src/babel-config.ts b/code/lib/cli/src/babel-config.ts index e97b51f0328d..c918ba78cff7 100644 --- a/code/lib/cli/src/babel-config.ts +++ b/code/lib/cli/src/babel-config.ts @@ -2,15 +2,63 @@ import { writeFile, pathExists } from 'fs-extra'; import { logger } from '@storybook/node-logger'; import path from 'path'; import prompts from 'prompts'; -import chalk from 'chalk'; import { JsPackageManagerFactory } from './js-package-manager'; export const generateStorybookBabelConfigInCWD = async () => { const target = process.cwd(); return generateStorybookBabelConfig({ target }); }; + +export const getBabelPresets = ({ typescript, jsx }: { typescript: boolean; jsx: boolean }) => { + const dependencies = ['@babel/preset-env']; + + if (typescript) { + dependencies.push('@babel/preset-typescript'); + } + + if (jsx) { + dependencies.push('@babel/preset-react'); + } + + return dependencies; +}; + +export const writeBabelConfigFile = async ({ + location, + typescript, + jsx, +}: { + location?: string; + typescript: boolean; + jsx: boolean; +}) => { + const fileLocation = location || path.join(process.cwd(), '.babelrc.json'); + + const presets: (string | [string, any])[] = [['@babel/preset-env', { targets: { chrome: 100 } }]]; + + if (typescript) { + presets.push('@babel/preset-typescript'); + } + + if (jsx) { + presets.push('@babel/preset-react'); + } + + const contents = JSON.stringify( + { + sourceType: 'unambiguous', + presets, + plugins: [], + }, + null, + 2 + ); + + await writeFile(fileLocation, contents); +}; + export const generateStorybookBabelConfig = async ({ target }: { target: string }) => { - logger.info(`Generating the storybook default babel config at ${target}`); + logger.info(`Generating the Storybook default babel config at ${target}`); const fileName = '.babelrc.json'; const location = path.join(target, fileName); @@ -31,12 +79,6 @@ export const generateStorybookBabelConfig = async ({ target }: { target: string } } - logger.info( - `The config will contain ${chalk.yellow( - '@babel/preset-env' - )} and you will be prompted for additional presets, if you wish to add them depending on your project needs.` - ); - const { typescript, jsx } = await prompts([ { type: 'confirm', @@ -52,48 +94,13 @@ export const generateStorybookBabelConfig = async ({ target }: { target: string }, ]); - const added = ['@babel/preset-env']; - const presets: (string | [string, any])[] = [['@babel/preset-env', { targets: { chrome: 100 } }]]; - - if (typescript) { - added.push('@babel/preset-typescript'); - presets.push('@babel/preset-typescript'); - } - - if (jsx) { - added.push('@babel/preset-react'); - presets.push('@babel/preset-react'); - } - - const contents = JSON.stringify( - { - sourceType: 'unambiguous', - presets, - plugins: [], - }, - null, - 2 - ); + const dependencies = getBabelPresets({ typescript, jsx }); logger.info(`Writing file to ${location}`); - await writeFile(location, contents); + await writeBabelConfigFile({ location, typescript, jsx }); - const { runInstall } = await prompts({ - type: 'confirm', - initial: true, - name: 'runInstall', - message: `Shall we install the required dependencies now? (${added.join(', ')})`, - }); + const packageManager = JsPackageManagerFactory.getPackageManager(); - if (runInstall) { - logger.info(`Installing dependencies...`); - - const packageManager = JsPackageManagerFactory.getPackageManager(); - - await packageManager.addDependencies({ installAsDevDependencies: true }, added); - } else { - logger.info( - `⚠️ Please remember to install the required dependencies yourself: (${added.join(', ')})` - ); - } + logger.info(`Installing dependencies (${dependencies.join(', ')})`); + await packageManager.addDependencies({ installAsDevDependencies: true }, dependencies); }; diff --git a/code/lib/cli/src/generators/REACT_SCRIPTS/index.ts b/code/lib/cli/src/generators/REACT_SCRIPTS/index.ts index 1871a13faedb..8948aea95500 100644 --- a/code/lib/cli/src/generators/REACT_SCRIPTS/index.ts +++ b/code/lib/cli/src/generators/REACT_SCRIPTS/index.ts @@ -54,8 +54,7 @@ const generator: Generator = async (packageManager, npmOptions, options) => { extraAddons, extraPackages, staticDir: fs.existsSync(path.resolve('./public')) ? 'public' : undefined, - addBabel: false, - addESLint: true, + skipBabel: true, extraMain, }); }; diff --git a/code/lib/cli/src/generators/baseGenerator.ts b/code/lib/cli/src/generators/baseGenerator.ts index 9a44785b7146..5f94d6d969bc 100644 --- a/code/lib/cli/src/generators/baseGenerator.ts +++ b/code/lib/cli/src/generators/baseGenerator.ts @@ -3,14 +3,19 @@ import fse from 'fs-extra'; import { dedent } from 'ts-dedent'; import type { NpmOptions } from '../NpmOptions'; import type { SupportedRenderers, SupportedFrameworks, Builder } from '../project_types'; -import { externalFrameworks, CoreBuilder } from '../project_types'; -import { getBabelDependencies, copyTemplateFiles } from '../helpers'; +import { SupportedLanguage, externalFrameworks, CoreBuilder } from '../project_types'; +import { copyTemplateFiles, paddedLog } from '../helpers'; import { configureMain, configurePreview } from './configure'; import type { JsPackageManager } from '../js-package-manager'; import { getPackageDetails } from '../js-package-manager'; -import { generateStorybookBabelConfigInCWD } from '../babel-config'; +import { getBabelPresets, writeBabelConfigFile } from '../babel-config'; import packageVersions from '../versions'; import type { FrameworkOptions, GeneratorOptions } from './types'; +import { + configureEslintPlugin, + extractEslintInfo, + suggestESLintPlugin, +} from '../automigrate/helpers/eslintPlugin'; const defaultOptions: FrameworkOptions = { extraPackages: [], @@ -19,8 +24,7 @@ const defaultOptions: FrameworkOptions = { addScripts: true, addMainFile: true, addComponents: true, - addBabel: false, - addESLint: false, + skipBabel: false, extraMain: undefined, framework: undefined, extensions: undefined, @@ -154,8 +158,7 @@ export async function baseGenerator( addScripts, addMainFile, addComponents, - addBabel, - addESLint, + skipBabel, extraMain, extensions, storybookConfigFolder, @@ -260,20 +263,60 @@ export async function baseGenerator( await configurePreview({ frameworkPreviewParts, storybookConfigFolder, language, rendererId }); - const babelDependencies = - addBabel && builder !== CoreBuilder.Vite - ? await getBabelDependencies(packageManager, packageJson) - : []; - const isNewFolder = !files.some( - (fname) => fname.startsWith('.babel') || fname.startsWith('babel') || fname === 'package.json' - ); - if (isNewFolder) { - await generateStorybookBabelConfigInCWD(); + const depsToInstall = [...versionedPackages]; + + // Add basic babel config for a select few frameworks that need it, if they do not have a babel config file already + if (builder !== CoreBuilder.Vite && !skipBabel) { + const frameworksThatNeedBabelConfig = [ + '@storybook/react-webpack5', + '@storybook/vue-webpack5', + '@storybook/vue3-webpack5', + '@storybook/html-webpack5', + '@storybook/web-components-webpack5', + ]; + const needsBabelConfig = frameworkPackages.find((pkg) => + frameworksThatNeedBabelConfig.includes(pkg) + ); + const hasNoBabelFile = !files.some( + (fname) => fname.startsWith('.babel') || fname.startsWith('babel') + ); + + if (hasNoBabelFile && needsBabelConfig) { + const isTypescript = language !== SupportedLanguage.JAVASCRIPT; + const isReact = rendererId === 'react'; + depsToInstall.push( + ...getBabelPresets({ + typescript: isTypescript, + jsx: isReact, + }) + ); + await writeBabelConfigFile({ + typescript: isTypescript, + jsx: isReact, + }); + } } - const depsToInstall = [...versionedPackages, ...babelDependencies]; + try { + if (process.env.CI !== 'true') { + const { hasEslint, isStorybookPluginInstalled, eslintConfigFile } = await extractEslintInfo( + packageManager + ); + + if (hasEslint && !isStorybookPluginInstalled) { + const shouldInstallESLintPlugin = await suggestESLintPlugin(); + if (shouldInstallESLintPlugin) { + depsToInstall.push('eslint-plugin-storybook'); + await configureEslintPlugin(eslintConfigFile, packageManager); + } + } + } + } catch (err) { + // any failure regarding configuring the eslint plugin should not fail the whole generator + } if (depsToInstall.length > 0) { + paddedLog('Installing Storybook dependencies'); await packageManager.addDependencies({ ...npmOptions, packageJson }, depsToInstall); } @@ -283,10 +326,6 @@ export async function baseGenerator( }); } - if (addESLint) { - await packageManager.addESLintConfig(); - } - if (addComponents) { const templateLocation = hasFrameworkTemplates(framework) ? framework : rendererId; await copyTemplateFiles({ diff --git a/code/lib/cli/src/generators/types.ts b/code/lib/cli/src/generators/types.ts index bb62010bbd58..6ef446c6538c 100644 --- a/code/lib/cli/src/generators/types.ts +++ b/code/lib/cli/src/generators/types.ts @@ -18,8 +18,7 @@ export interface FrameworkOptions { addScripts?: boolean; addMainFile?: boolean; addComponents?: boolean; - addBabel?: boolean; - addESLint?: boolean; + skipBabel?: boolean; extraMain?: any; extensions?: string[]; framework?: Record; diff --git a/code/lib/cli/src/helpers.ts b/code/lib/cli/src/helpers.ts index b8cd29253ebd..a1e162a3d263 100644 --- a/code/lib/cli/src/helpers.ts +++ b/code/lib/cli/src/helpers.ts @@ -119,6 +119,7 @@ export function codeLog(codeLines: string[], leftPadAmount?: number) { /** * Detect if any babel dependencies need to be added to the project + * This is currently used by react-native generator * @param {Object} packageJson The current package.json so we can inspect its contents * @returns {Array} Contains the packages and versions that need to be installed * @example diff --git a/code/lib/cli/src/js-package-manager/JsPackageManager.ts b/code/lib/cli/src/js-package-manager/JsPackageManager.ts index 8e8ae0b2ce1c..f2d1c1b3ed3d 100644 --- a/code/lib/cli/src/js-package-manager/JsPackageManager.ts +++ b/code/lib/cli/src/js-package-manager/JsPackageManager.ts @@ -365,25 +365,6 @@ export abstract class JsPackageManager { }); } - public async addESLintConfig() { - const packageJson = await this.retrievePackageJson(); - await this.writePackageJson({ - ...packageJson, - eslintConfig: { - ...packageJson.eslintConfig, - overrides: [ - ...(packageJson.eslintConfig?.overrides || []), - { - files: ['**/*.stories.*'], - rules: { - 'import/no-anonymous-default-export': 'off', - }, - }, - ], - }, - }); - } - public async addScripts(scripts: Record) { const packageJson = await this.retrievePackageJson(); await this.writePackageJson({ From aff3dc229763f8ba45e258de271605641f0d0598 Mon Sep 17 00:00:00 2001 From: Yann Braga Date: Mon, 15 May 2023 15:34:29 +0200 Subject: [PATCH 2/5] Merge pull request #22561 from storybookjs/fix/remove-automigrate-from-initiate CLI: Remove automigrate reference from init command --- code/lib/cli/src/initiate.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/code/lib/cli/src/initiate.ts b/code/lib/cli/src/initiate.ts index 62797d5db756..97b2683e0983 100644 --- a/code/lib/cli/src/initiate.ts +++ b/code/lib/cli/src/initiate.ts @@ -35,9 +35,7 @@ import serverGenerator from './generators/SERVER'; import type { JsPackageManager } from './js-package-manager'; import { JsPackageManagerFactory, useNpmWarning } from './js-package-manager'; import type { NpmOptions } from './NpmOptions'; -import { automigrate } from './automigrate'; import type { CommandOptions } from './generators/types'; -import { initFixes } from './automigrate/fixes'; import { HandledError } from './HandledError'; const logger = console; @@ -318,16 +316,6 @@ async function doInitiate(options: CommandOptions, pkg: PackageJson): Promise Date: Mon, 15 May 2023 16:39:04 +0200 Subject: [PATCH 3/5] Merge pull request #22559 from storybookjs/fix/builder-option CLI: Fix `getFrameworkPackage` logic --- code/lib/cli/src/generators/baseGenerator.ts | 21 ++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/code/lib/cli/src/generators/baseGenerator.ts b/code/lib/cli/src/generators/baseGenerator.ts index 5f94d6d969bc..f4231f56c21b 100644 --- a/code/lib/cli/src/generators/baseGenerator.ts +++ b/code/lib/cli/src/generators/baseGenerator.ts @@ -58,13 +58,30 @@ const getExternalFramework = (framework: string) => const getFrameworkPackage = (framework: string, renderer: string, builder: string) => { const externalFramework = getExternalFramework(framework); + const storybookBuilder = builder?.replace(/^@storybook\/builder-/, ''); + const storybookFramework = framework?.replace(/^@storybook\//, ''); if (externalFramework === undefined) { - return framework ? `@storybook/${framework}` : `@storybook/${renderer}-${builder}`; + const frameworkPackage = framework + ? `@storybook/${storybookFramework}` + : `@storybook/${renderer}-${storybookBuilder}`; + + if (packageVersions[frameworkPackage as keyof typeof packageVersions]) { + return frameworkPackage; + } + + throw new Error( + dedent` + Could not find framework package: ${frameworkPackage}. + Make sure this package exists, and if it does, please file an issue as this might be a bug in Storybook. + ` + ); } if (externalFramework.frameworks !== undefined) { - return externalFramework.frameworks.find((item) => item.match(new RegExp(`-${builder}`))); + return externalFramework.frameworks.find((item) => + item.match(new RegExp(`-${storybookBuilder}`)) + ); } return externalFramework.packageName; From 5fe6872bbcf80635a00142ffa2b3b7b6654da8bf Mon Sep 17 00:00:00 2001 From: jonniebigodes Date: Mon, 15 May 2023 15:44:03 +0100 Subject: [PATCH 4/5] Merge pull request #22433 from miily8310s/code-snippets-for-svelte Chore:(Docs) Add TS version of Svelte snippet(get-started) --- docs/get-started/setup.md | 1 + docs/get-started/whats-a-story.md | 2 ++ docs/get-started/why-storybook.md | 1 + .../svelte/button-story-with-args.ts-4-9.mdx | 26 ++++++++++++++ .../svelte/button-story-with-args.ts.mdx | 26 ++++++++++++++ docs/snippets/svelte/button-story.ts-4-9.mdx | 34 +++++++++++++++++++ docs/snippets/svelte/button-story.ts.mdx | 34 +++++++++++++++++++ .../svelte/histogram-story.ts-4-9.mdx | 28 +++++++++++++++ docs/snippets/svelte/histogram-story.ts.mdx | 28 +++++++++++++++ .../snippets/svelte/your-component.ts-4-9.mdx | 26 ++++++++++++++ docs/snippets/svelte/your-component.ts.mdx | 26 ++++++++++++++ 11 files changed, 232 insertions(+) create mode 100644 docs/snippets/svelte/button-story-with-args.ts-4-9.mdx create mode 100644 docs/snippets/svelte/button-story-with-args.ts.mdx create mode 100644 docs/snippets/svelte/button-story.ts-4-9.mdx create mode 100644 docs/snippets/svelte/button-story.ts.mdx create mode 100644 docs/snippets/svelte/histogram-story.ts-4-9.mdx create mode 100644 docs/snippets/svelte/histogram-story.ts.mdx create mode 100644 docs/snippets/svelte/your-component.ts-4-9.mdx create mode 100644 docs/snippets/svelte/your-component.ts.mdx diff --git a/docs/get-started/setup.md b/docs/get-started/setup.md index a781b7d7bf8d..c454a7648edc 100644 --- a/docs/get-started/setup.md +++ b/docs/get-started/setup.md @@ -20,6 +20,7 @@ Pick a simple component from your project, like a Button, and write a `.stories. 'web-components/your-component.js.mdx', 'web-components/your-component.ts.mdx', 'svelte/your-component.js.mdx', + 'svelte/your-component.ts.mdx', 'html/your-component.js.mdx', 'html/your-component.ts.mdx', 'preact/your-component.js.mdx', diff --git a/docs/get-started/whats-a-story.md b/docs/get-started/whats-a-story.md index 513fd58c9ee4..5adfded2f947 100644 --- a/docs/get-started/whats-a-story.md +++ b/docs/get-started/whats-a-story.md @@ -22,6 +22,7 @@ Let’s start with the `Button` component. A story is a function that describes 'web-components/button-story.js.mdx', 'web-components/button-story.ts.mdx', 'svelte/button-story.js.mdx', + 'svelte/button-story.ts.mdx', 'html/button-story.js.mdx', 'html/button-story.ts.mdx', 'preact/button-story.js.mdx', @@ -54,6 +55,7 @@ The above story definition can be further improved to take advantage of [Storybo 'web-components/button-story-with-args.js.mdx', 'web-components/button-story-with-args.ts.mdx', 'svelte/button-story-with-args.js.mdx', + 'svelte/button-story-with-args.ts.mdx', 'html/button-story-with-args.js.mdx', 'html/button-story-with-args.ts.mdx', 'preact/button-story-with-args.js.mdx', diff --git a/docs/get-started/why-storybook.md b/docs/get-started/why-storybook.md index 015d6eb5bdcc..39451d4ee4b7 100644 --- a/docs/get-started/why-storybook.md +++ b/docs/get-started/why-storybook.md @@ -47,6 +47,7 @@ You write stories for granular UI component variation and then use those stories 'vue/histogram-story.3.js.mdx', 'vue/histogram-story.3.ts.mdx', 'svelte/histogram-story.js.mdx', + 'svelte/histogram-story.ts.mdx', 'web-components/histogram-story.js.mdx', 'web-components/histogram-story.ts.mdx', 'preact/histogram-story.js.mdx', diff --git a/docs/snippets/svelte/button-story-with-args.ts-4-9.mdx b/docs/snippets/svelte/button-story-with-args.ts-4-9.mdx new file mode 100644 index 000000000000..ef414eec4907 --- /dev/null +++ b/docs/snippets/svelte/button-story-with-args.ts-4-9.mdx @@ -0,0 +1,26 @@ +```ts +// Button.stories.ts + +import type { Meta, StoryObj } from '@storybook/svelte'; + +import Button from './Button.svelte'; + +const meta = { + /* πŸ‘‡ The title prop is optional. + * See https://storybook.js.org/docs/svelte/configure/overview#configure-story-loading + * to learn how to generate automatic titles + */ + title: 'Button', + component: Button, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Primary: Story = { + args: { + primary: true, + label: 'Button', + }, +}; +``` diff --git a/docs/snippets/svelte/button-story-with-args.ts.mdx b/docs/snippets/svelte/button-story-with-args.ts.mdx new file mode 100644 index 000000000000..f0c7d4a541da --- /dev/null +++ b/docs/snippets/svelte/button-story-with-args.ts.mdx @@ -0,0 +1,26 @@ +```ts +// Button.stories.ts + +import type { Meta, StoryObj } from '@storybook/svelte'; + +import Button from './Button.svelte'; + +const meta: Meta = { + /* πŸ‘‡ The title prop is optional. + * See https://storybook.js.org/docs/svelte/configure/overview#configure-story-loading + * to learn how to generate automatic titles + */ + title: 'Button', + component: Button, +}; + +export default meta; +type Story = StoryObj; + +export const Primary: Story = { + args: { + primary: true, + label: 'Button', + }, +}; +``` diff --git a/docs/snippets/svelte/button-story.ts-4-9.mdx b/docs/snippets/svelte/button-story.ts-4-9.mdx new file mode 100644 index 000000000000..24e5c23b8408 --- /dev/null +++ b/docs/snippets/svelte/button-story.ts-4-9.mdx @@ -0,0 +1,34 @@ +```ts +// Button.stories.ts + +import type { Meta, StoryObj } from '@storybook/svelte'; + +import Button from './Button.svelte'; + +const meta = { + /* πŸ‘‡ The title prop is optional. + * See https://storybook.js.org/docs/svelte/configure/overview#configure-story-loading + * to learn how to generate automatic titles + */ + title: 'Button', + component: Button, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/* + *πŸ‘‡ Render functions are a framework specific feature to allow you control on how the component renders. + * See https://storybook.js.org/docs/svelte/api/csf + * to learn how to use render functions. + */ +export const Primary: Story = { + render: () => ({ + Component: Button, + props: { + primary: true, + label: 'Button', + }, + }), +}; +``` diff --git a/docs/snippets/svelte/button-story.ts.mdx b/docs/snippets/svelte/button-story.ts.mdx new file mode 100644 index 000000000000..cc4dfc67c2ff --- /dev/null +++ b/docs/snippets/svelte/button-story.ts.mdx @@ -0,0 +1,34 @@ +```ts +// Button.stories.ts + +import type { Meta, StoryObj } from '@storybook/svelte'; + +import Button from './Button.svelte'; + +const meta: Meta = { + /* πŸ‘‡ The title prop is optional. + * See https://storybook.js.org/docs/svelte/configure/overview#configure-story-loading + * to learn how to generate automatic titles + */ + title: 'Button', + component: Button, +}; + +export default meta; +type Story = StoryObj; + +/* + *πŸ‘‡ Render functions are a framework specific feature to allow you control on how the component renders. + * See https://storybook.js.org/docs/svelte/api/csf + * to learn how to use render functions. + */ +export const Primary: Story = { + render: () => ({ + Component: Button, + props: { + primary: true, + label: 'Button', + }, + }), +}; +``` diff --git a/docs/snippets/svelte/histogram-story.ts-4-9.mdx b/docs/snippets/svelte/histogram-story.ts-4-9.mdx new file mode 100644 index 000000000000..d0bf44129ec4 --- /dev/null +++ b/docs/snippets/svelte/histogram-story.ts-4-9.mdx @@ -0,0 +1,28 @@ +```ts +// Histogram.stories.ts + +import type { Meta, StoryObj } from '@storybook/svelte'; + +import Histogram from './Histogram.svelte'; + +const meta = { + /* πŸ‘‡ The title prop is optional. + * See https://storybook.js.org/docs/svelte/configure/overview#configure-story-loading + * to learn how to generate automatic titles + */ + title: 'Histogram', + component: Histogram, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + dataType: 'latency', + showHistogramLabels: true, + histogramAccentColor: '#1EA7FD', + label: 'Latency distribution', + }, +}; +``` diff --git a/docs/snippets/svelte/histogram-story.ts.mdx b/docs/snippets/svelte/histogram-story.ts.mdx new file mode 100644 index 000000000000..924161cf555b --- /dev/null +++ b/docs/snippets/svelte/histogram-story.ts.mdx @@ -0,0 +1,28 @@ +```ts +// Histogram.stories.ts + +import type { Meta, StoryObj } from '@storybook/svelte'; + +import Histogram from './Histogram.svelte'; + +const meta: Meta = { + /* πŸ‘‡ The title prop is optional. + * See https://storybook.js.org/docs/svelte/configure/overview#configure-story-loading + * to learn how to generate automatic titles + */ + title: 'Histogram', + component: Histogram, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + dataType: 'latency', + showHistogramLabels: true, + histogramAccentColor: '#1EA7FD', + label: 'Latency distribution', + }, +}; +``` diff --git a/docs/snippets/svelte/your-component.ts-4-9.mdx b/docs/snippets/svelte/your-component.ts-4-9.mdx new file mode 100644 index 000000000000..02350085549c --- /dev/null +++ b/docs/snippets/svelte/your-component.ts-4-9.mdx @@ -0,0 +1,26 @@ +```ts +// YourComponent.stories.ts + +import type { Meta, StoryObj } from '@storybook/svelte'; + +import YourComponent from './YourComponent.svelte'; + +//πŸ‘‡This default export determines where your story goes in the story list +const meta = { + /* πŸ‘‡ The title prop is optional. + * See https://storybook.js.org/docs/svelte/configure/overview#configure-story-loading + * to learn how to generate automatic titles + */ + title: 'YourComponent', + component: YourComponent, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const FirstStory: Story = { + args: { + //πŸ‘‡ The args you need here will depend on your component + }, +}; +``` diff --git a/docs/snippets/svelte/your-component.ts.mdx b/docs/snippets/svelte/your-component.ts.mdx new file mode 100644 index 000000000000..1faabd5c39b3 --- /dev/null +++ b/docs/snippets/svelte/your-component.ts.mdx @@ -0,0 +1,26 @@ +```ts +// YourComponent.stories.ts + +import type { Meta, StoryObj } from '@storybook/svelte'; + +import YourComponent from './YourComponent.svelte'; + +//πŸ‘‡This default export determines where your story goes in the story list +const meta: Meta = { + /* πŸ‘‡ The title prop is optional. + * See https://storybook.js.org/docs/svelte/configure/overview#configure-story-loading + * to learn how to generate automatic titles + */ + title: 'YourComponent', + component: YourComponent, +}; + +export default meta; +type Story = StoryObj; + +export const FirstStory: Story = { + args: { + //πŸ‘‡ The args you need here will depend on your component + }, +}; +``` From 7ed4d265fdf6dcb704855ee89b7be55d7afc58c5 Mon Sep 17 00:00:00 2001 From: Tom Coleman Date: Mon, 1 May 2023 09:41:11 +1000 Subject: [PATCH 5/5] Merge pull request #22135 from storybookjs/tom/22099-fix-conditional-context Core: Move `prepareContext` into `store.getStoryContext()` --- .../src/client/docs/sourceDecorator.ts | 4 +- .../preview-web/PreviewWithSelection.tsx | 6 +- .../preview-web/render/StoryRender.test.ts | 44 ------- .../modules/preview-web/render/StoryRender.ts | 27 +---- .../src/modules/store/StoryStore.test.ts | 18 ++- .../src/modules/store/StoryStore.ts | 19 ++- .../src/modules/store/args.test.ts | 12 +- .../lib/preview-api/src/modules/store/args.ts | 6 +- .../modules/store/csf/prepareStory.test.ts | 112 ++++++++++++------ .../src/modules/store/csf/prepareStory.ts | 97 ++++++++------- .../modules/store/csf/testing-utils/index.ts | 4 +- code/lib/types/src/modules/story.ts | 1 - .../html/src/docs/sourceDecorator.test.ts | 2 + .../html/src/docs/sourceDecorator.ts | 4 +- .../react/src/docs/jsxDecorator.test.tsx | 1 + .../renderers/react/src/docs/jsxDecorator.tsx | 4 +- .../svelte/src/docs/sourceDecorator.ts | 4 +- .../renderers/vue/src/docs/sourceDecorator.ts | 4 +- .../vue3/src/docs/sourceDecorator.ts | 4 +- .../src/docs/sourceDecorator.test.ts | 1 + .../src/docs/sourceDecorator.ts | 4 +- code/ui/blocks/src/blocks/Source.tsx | 9 +- code/ui/blocks/src/blocks/useArgs.ts | 9 +- 23 files changed, 199 insertions(+), 197 deletions(-) diff --git a/code/frameworks/angular/src/client/docs/sourceDecorator.ts b/code/frameworks/angular/src/client/docs/sourceDecorator.ts index e86df2a05835..8a80008cbfee 100644 --- a/code/frameworks/angular/src/client/docs/sourceDecorator.ts +++ b/code/frameworks/angular/src/client/docs/sourceDecorator.ts @@ -38,8 +38,8 @@ export const sourceDecorator = ( useEffect(() => { if (toEmit) { - const { id, args } = context; - channel.emit(SNIPPET_RENDERED, { id, args, source: toEmit, format: 'angular' }); + const { id, unmappedArgs } = context; + channel.emit(SNIPPET_RENDERED, { id, args: unmappedArgs, source: toEmit, format: 'angular' }); } }); diff --git a/code/lib/preview-api/src/modules/preview-web/PreviewWithSelection.tsx b/code/lib/preview-api/src/modules/preview-web/PreviewWithSelection.tsx index 269d05bf1e39..01f8172ad18c 100644 --- a/code/lib/preview-api/src/modules/preview-web/PreviewWithSelection.tsx +++ b/code/lib/preview-api/src/modules/preview-web/PreviewWithSelection.tsx @@ -380,7 +380,7 @@ export class PreviewWithSelection extends Preview extends Preview extends Preview { await render.renderToElement({} as any); expect(story.playFunction).not.toHaveBeenCalled(); }); - - it('passes the initialArgs to loaders and render function if forceInitialArgs is true', async () => { - const story = { - id: 'id', - title: 'title', - name: 'name', - tags: [], - initialArgs: { a: 'b' }, - applyLoaders: jest.fn(), - unboundStoryFn: jest.fn(), - playFunction: jest.fn(), - prepareContext: jest.fn((ctx) => ctx), - }; - - const renderToScreen = jest.fn(); - - const render = new StoryRender( - new Channel(), - { getStoryContext: () => ({ args: { a: 'c ' } }) } as any, - renderToScreen as any, - {} as any, - entry.id, - 'story', - { forceInitialArgs: true }, - story as any - ); - - await render.renderToElement({} as any); - - expect(story.applyLoaders).toHaveBeenCalledWith( - expect.objectContaining({ - args: { a: 'b' }, - }) - ); - - expect(renderToScreen).toHaveBeenCalledWith( - expect.objectContaining({ - storyContext: expect.objectContaining({ - args: { a: 'b' }, - }), - }), - expect.any(Object) - ); - }); }); diff --git a/code/lib/preview-api/src/modules/preview-web/render/StoryRender.ts b/code/lib/preview-api/src/modules/preview-web/render/StoryRender.ts index 5602bbc4313d..b50489ecb785 100644 --- a/code/lib/preview-api/src/modules/preview-web/render/StoryRender.ts +++ b/code/lib/preview-api/src/modules/preview-web/render/StoryRender.ts @@ -136,7 +136,8 @@ export class StoryRender implements Render implements Render implements Render - prepareContext({ - ...this.storyContext(), - ...(this.renderOptions.forceInitialArgs && { args: initialArgs }), - } as StoryContext); - let loadedContext: Awaited>; await this.runPhase(abortSignal, 'loading', async () => { loadedContext = await applyLoaders({ - ...getCurrentContext(), + ...this.storyContext(), viewMode: this.viewMode, } as StoryContextForLoaders); }); @@ -197,7 +182,7 @@ export class StoryRender implements Render ({ + ...jest.requireActual('./csf/prepareStory'), prepareStory: jest.fn(jest.requireActual('./csf/prepareStory').prepareStory), })); jest.mock('./csf/processCSFFile', () => ({ @@ -425,6 +426,20 @@ describe('StoryStore', () => { }); }); + it('can force initial args', async () => { + const store = new StoryStore(); + store.setProjectAnnotations(projectAnnotations); + store.initialize({ storyIndex, importFn, cache: false }); + + const story = await store.loadStory({ storyId: 'component-one--a' }); + + store.args.update(story.id, { foo: 'bar' }); + + expect(store.getStoryContext(story, { forceInitialArgs: true })).toMatchObject({ + args: { foo: 'a' }, + }); + }); + it('returns the same hooks each time', async () => { const store = new StoryStore(); store.setProjectAnnotations(projectAnnotations); @@ -735,7 +750,6 @@ describe('StoryStore', () => { "fileName": "./src/ComponentOne.stories.js", }, "playFunction": undefined, - "prepareContext": [Function], "story": "A", "storyFn": [Function], "subcomponents": undefined, @@ -781,7 +795,6 @@ describe('StoryStore', () => { "fileName": "./src/ComponentOne.stories.js", }, "playFunction": undefined, - "prepareContext": [Function], "story": "B", "storyFn": [Function], "subcomponents": undefined, @@ -827,7 +840,6 @@ describe('StoryStore', () => { "fileName": "./src/ComponentTwo.stories.js", }, "playFunction": undefined, - "prepareContext": [Function], "story": "C", "storyFn": [Function], "subcomponents": undefined, diff --git a/code/lib/preview-api/src/modules/store/StoryStore.ts b/code/lib/preview-api/src/modules/store/StoryStore.ts index 9a6fbf4543f1..1fc0fa547d2a 100644 --- a/code/lib/preview-api/src/modules/store/StoryStore.ts +++ b/code/lib/preview-api/src/modules/store/StoryStore.ts @@ -30,7 +30,13 @@ import { HooksContext } from '../addons'; import { StoryIndexStore } from './StoryIndexStore'; import { ArgsStore } from './ArgsStore'; import { GlobalsStore } from './GlobalsStore'; -import { processCSFFile, prepareStory, prepareMeta, normalizeProjectAnnotations } from './csf'; +import { + processCSFFile, + prepareStory, + prepareMeta, + normalizeProjectAnnotations, + prepareContext, +} from './csf'; const CSF_CACHE_SIZE = 1000; const STORY_CACHE_SIZE = 10000; @@ -276,16 +282,17 @@ export class StoryStore { // A prepared story does not include args, globals or hooks. These are stored in the story store // and updated separtely to the (immutable) story. getStoryContext( - story: PreparedStory - ): Omit, 'viewMode'> { + story: PreparedStory, + { forceInitialArgs = false } = {} + ): Omit { if (!this.globals) throw new Error(`getStoryContext called before initialization`); - return { + return prepareContext({ ...story, - args: this.args.get(story.id), + args: forceInitialArgs ? story.initialArgs : this.args.get(story.id), globals: this.globals.get(), hooks: this.hooks[story.id] as unknown, - }; + }); } cleanupStory(story: PreparedStory): void { diff --git a/code/lib/preview-api/src/modules/store/args.test.ts b/code/lib/preview-api/src/modules/store/args.test.ts index e134129acaed..f567d54cbe5e 100644 --- a/code/lib/preview-api/src/modules/store/args.test.ts +++ b/code/lib/preview-api/src/modules/store/args.test.ts @@ -276,8 +276,12 @@ describe('groupArgsByTarget', () => { it('groups targeted args', () => { const groups = groupArgsByTarget({ args: { a: 1, b: 2, c: 3 }, - argTypes: { a: { target: 'group1' }, b: { target: 'group2' }, c: { target: 'group2' } }, - } as any); + argTypes: { + a: { name: 'a', target: 'group1' }, + b: { name: 'b', target: 'group2' }, + c: { name: 'c', target: 'group2' }, + }, + }); expect(groups).toEqual({ group1: { a: 1, @@ -292,8 +296,8 @@ describe('groupArgsByTarget', () => { it('groups non-targetted args into a group with no name', () => { const groups = groupArgsByTarget({ args: { a: 1, b: 2, c: 3 }, - argTypes: { b: { name: 'b', target: 'group2' }, c: {} }, - } as any); + argTypes: { a: { name: 'a' }, b: { name: 'b', target: 'group2' }, c: { name: 'c' } }, + }); expect(groups).toEqual({ [UNTARGETED]: { a: 1, diff --git a/code/lib/preview-api/src/modules/store/args.ts b/code/lib/preview-api/src/modules/store/args.ts index 4dcccb6261a8..2634015bce76 100644 --- a/code/lib/preview-api/src/modules/store/args.ts +++ b/code/lib/preview-api/src/modules/store/args.ts @@ -148,7 +148,7 @@ export const UNTARGETED = 'UNTARGETED'; export function groupArgsByTarget({ args, argTypes, -}: StoryContext) { +}: Pick, 'args' | 'argTypes'>) { const groupedArgs: Record> = {}; (Object.entries(args) as [keyof TArgs, any][]).forEach(([name, value]) => { const { target = UNTARGETED } = (argTypes[name] || {}) as { target?: string }; @@ -159,6 +159,8 @@ export function groupArgsByTarget({ return groupedArgs; } -export function noTargetArgs(context: StoryContext) { +export function noTargetArgs( + context: Pick, 'args' | 'argTypes'> +) { return groupArgsByTarget(context)[UNTARGETED]; } diff --git a/code/lib/preview-api/src/modules/store/csf/prepareStory.test.ts b/code/lib/preview-api/src/modules/store/csf/prepareStory.test.ts index 4cf8f51a3860..a7cb1d971e6d 100644 --- a/code/lib/preview-api/src/modules/store/csf/prepareStory.test.ts +++ b/code/lib/preview-api/src/modules/store/csf/prepareStory.test.ts @@ -6,7 +6,7 @@ import type { Renderer, ArgsEnhancer, PlayFunctionContext, SBScalarType } from ' import { addons, HooksContext } from '../../addons'; import { UNTARGETED } from '../args'; -import { prepareStory, prepareMeta } from './prepareStory'; +import { prepareStory, prepareMeta, prepareContext } from './prepareStory'; jest.mock('@storybook/global', () => ({ global: { @@ -24,6 +24,15 @@ const stringType: SBScalarType = { name: 'string' }; const numberType: SBScalarType = { name: 'number' }; const booleanType: SBScalarType = { name: 'boolean' }; +// Extra fields that must be added to the story context after enhancers +const storyContextExtras = () => ({ + hooks: new HooksContext(), + viewMode: 'story' as const, + loaded: {}, + abortSignal: new AbortController().signal, + canvasElement: {}, +}); + describe('prepareStory', () => { describe('tags', () => { it('story tags override component', () => { @@ -403,8 +412,8 @@ describe('prepareStory', () => { { render: renderMock } ); - const context = story.prepareContext({ args: story.initialArgs, ...story } as any); - story.undecoratedStoryFn(context); + const context = prepareContext({ args: story.initialArgs, globals: {}, ...story }); + story.undecoratedStoryFn({ ...context, ...storyContextExtras() }); expect(renderMock).toHaveBeenCalledWith( { one: 'mapped', two: 2, three: 3 }, expect.objectContaining({ args: { one: 'mapped', two: 2, three: 3 } }) @@ -499,17 +508,63 @@ describe('prepareStory', () => { ); const hooks = new HooksContext(); - const context = story.prepareContext({ args: story.initialArgs, hooks, ...story } as any); - story.unboundStoryFn(context); + const context = prepareContext({ args: story.initialArgs, globals: {}, ...story }); + story.unboundStoryFn({ ...context, ...storyContextExtras(), hooks }); - expect(ctx1).toMatchObject({ args: { one: 'mapped-1' } }); - expect(ctx2).toMatchObject({ args: { one: 'mapped-1' } }); - expect(ctx3).toMatchObject({ args: { one: 'mapped-1' } }); + expect(ctx1).toMatchObject({ unmappedArgs: { one: 1 }, args: { one: 'mapped-1' } }); + expect(ctx2).toMatchObject({ unmappedArgs: { one: 1 }, args: { one: 'mapped-1' } }); + expect(ctx3).toMatchObject({ unmappedArgs: { one: 1 }, args: { one: 'mapped-1' } }); hooks.clean(); }); }); + describe('mapping', () => { + it('maps labels to values in prepareContext', () => { + const story = prepareStory( + { + id, + name, + argTypes: { + one: { name: 'one', mapping: { 1: 'mapped-1' } }, + }, + moduleExport, + }, + { id, title }, + { render: jest.fn() } + ); + + const context = prepareContext({ args: { one: 1 }, globals: {}, ...story }); + expect(context).toMatchObject({ + args: { one: 'mapped-1' }, + }); + }); + + it('maps arrays of labels to values in prepareContext', () => { + const story = prepareStory( + { + id, + name, + argTypes: { + one: { name: 'one', mapping: { 1: 'mapped-1' } }, + }, + moduleExport, + }, + { id, title }, + { render: jest.fn() } + ); + + const context = prepareContext({ + args: { one: [1, 1] }, + globals: {}, + ...story, + }); + expect(context).toMatchObject({ + args: { one: ['mapped-1', 'mapped-1'] }, + }); + }); + }); + describe('with `FEATURES.argTypeTargetsV7`', () => { beforeEach(() => { global.FEATURES = { argTypeTargetsV7: true }; @@ -528,12 +583,8 @@ describe('prepareStory', () => { { render: renderMock } ); - const context = firstStory.prepareContext({ - args: firstStory.initialArgs, - hooks: new HooksContext(), - ...firstStory, - } as any); - firstStory.unboundStoryFn(context); + const context = prepareContext({ args: firstStory.initialArgs, globals: {}, ...firstStory }); + firstStory.unboundStoryFn({ ...context, ...storyContextExtras() }); expect(renderMock).toHaveBeenCalledWith( { a: 1 }, expect.objectContaining({ args: { a: 1 }, allArgs: { a: 1, b: 2 } }) @@ -554,12 +605,8 @@ describe('prepareStory', () => { { render: renderMock } ); - const context = firstStory.prepareContext({ - args: firstStory.initialArgs, - hooks: new HooksContext(), - ...firstStory, - } as any); - firstStory.unboundStoryFn(context); + const context = prepareContext({ args: firstStory.initialArgs, globals: {}, ...firstStory }); + firstStory.unboundStoryFn({ ...context, ...storyContextExtras() }); expect(renderMock).toHaveBeenCalledWith( { a: 1 }, expect.objectContaining({ args: { a: 1 }, allArgs: { a: 1, b: 2 } }) @@ -580,12 +627,8 @@ describe('prepareStory', () => { { render: renderMock } ); - const context = firstStory.prepareContext({ - args: firstStory.initialArgs, - hooks: new HooksContext(), - ...firstStory, - } as any); - firstStory.unboundStoryFn(context); + const context = prepareContext({ args: firstStory.initialArgs, globals: {}, ...firstStory }); + firstStory.unboundStoryFn({ ...context, ...storyContextExtras() }); expect(renderMock).toHaveBeenCalledWith( { a: 1 }, expect.objectContaining({ argsByTarget: { [UNTARGETED]: { a: 1 }, foo: { b: 2 } } }) @@ -606,12 +649,8 @@ describe('prepareStory', () => { { render: renderMock } ); - const context = firstStory.prepareContext({ - args: firstStory.initialArgs, - hooks: new HooksContext(), - ...firstStory, - } as any); - firstStory.unboundStoryFn(context); + const context = prepareContext({ args: firstStory.initialArgs, globals: {}, ...firstStory }); + firstStory.unboundStoryFn({ ...context, ...storyContextExtras() }); expect(renderMock).toHaveBeenCalledWith( {}, expect.objectContaining({ argsByTarget: { foo: { b: 2 } } }) @@ -630,12 +669,8 @@ describe('prepareStory', () => { { render: renderMock } ); - const context = firstStory.prepareContext({ - args: firstStory.initialArgs, - hooks: new HooksContext(), - ...firstStory, - } as any); - firstStory.unboundStoryFn(context); + const context = prepareContext({ args: firstStory.initialArgs, globals: {}, ...firstStory }); + firstStory.unboundStoryFn({ ...context, ...storyContextExtras() }); expect(renderMock).toHaveBeenCalledWith({}, expect.objectContaining({ argsByTarget: {} })); }); }); @@ -723,7 +758,6 @@ describe('prepareMeta', () => { unboundStoryFn, undecoratedStoryFn, playFunction, - prepareContext, // eslint-disable-next-line @typescript-eslint/naming-convention parameters: { __isArgsStory, ...parameters }, ...expectedPreparedMeta diff --git a/code/lib/preview-api/src/modules/store/csf/prepareStory.ts b/code/lib/preview-api/src/modules/store/csf/prepareStory.ts index b73a27eb0d96..e4bfb1937114 100644 --- a/code/lib/preview-api/src/modules/store/csf/prepareStory.ts +++ b/code/lib/preview-api/src/modules/store/csf/prepareStory.ts @@ -87,49 +87,6 @@ export function prepareStory( const decoratedStoryFn = applyHooks(applyDecorators)(undecoratedStoryFn, decorators); const unboundStoryFn = (context: StoryContext) => decoratedStoryFn(context); - // prepareContext is invoked at StoryRender.render() - // the context is prepared before invoking the render function, instead of here directly - // to ensure args don't loose there special properties set by the renderer - // eg. reactive proxies set by frameworks like SolidJS or Vue - const prepareContext = (context: StoryContext) => { - let finalContext: StoryContext = context; - - if (global.FEATURES?.argTypeTargetsV7) { - const argsByTarget = groupArgsByTarget(context); - finalContext = { - ...context, - allArgs: context.args, - argsByTarget, - args: argsByTarget[UNTARGETED] || {}, - }; - } - - const mappedArgs = Object.entries(finalContext.args).reduce((acc, [key, val]) => { - if (!finalContext.argTypes[key]?.mapping) { - acc[key] = val; - - return acc; - } - - const mappingFn = (originalValue: any) => - originalValue in finalContext.argTypes[key].mapping - ? finalContext.argTypes[key].mapping[originalValue] - : originalValue; - - acc[key] = Array.isArray(val) ? val.map(mappingFn) : mappingFn(val); - - return acc; - }, {} as Args); - - const includedArgs = Object.entries(mappedArgs).reduce((acc, [key, val]) => { - const argType = finalContext.argTypes[key] || {}; - if (includeConditionalArg(argType, mappedArgs, finalContext.globals)) acc[key] = val; - return acc; - }, {} as Args); - - return { ...finalContext, args: includedArgs }; - }; - const play = storyAnnotations?.play || componentAnnotations.play; const playFunction = @@ -156,7 +113,6 @@ export function prepareStory( unboundStoryFn, applyLoaders, playFunction, - prepareContext, }; } @@ -258,3 +214,56 @@ function preparePartialAnnotations( return withoutStoryIdentifiers; } + +// the context is prepared before invoking the render function, instead of here directly +// to ensure args don't loose there special properties set by the renderer +// eg. reactive proxies set by frameworks like SolidJS or Vue +export function prepareContext< + TRenderer extends Renderer, + TContext extends Pick, 'args' | 'argTypes' | 'globals'> +>( + context: TContext +): TContext & Pick, 'allArgs' | 'argsByTarget' | 'unmappedArgs'> { + const { args: unmappedArgs } = context; + + let targetedContext: TContext & + Pick, 'allArgs' | 'argsByTarget'> = { + ...context, + allArgs: undefined, + argsByTarget: undefined, + }; + if (global.FEATURES?.argTypeTargetsV7) { + const argsByTarget = groupArgsByTarget(context); + targetedContext = { + ...context, + allArgs: context.args, + argsByTarget, + args: argsByTarget[UNTARGETED] || {}, + }; + } + + const mappedArgs = Object.entries(targetedContext.args).reduce((acc, [key, val]) => { + if (!targetedContext.argTypes[key]?.mapping) { + acc[key] = val; + + return acc; + } + + const mappingFn = (originalValue: any) => + originalValue in targetedContext.argTypes[key].mapping + ? targetedContext.argTypes[key].mapping[originalValue] + : originalValue; + + acc[key] = Array.isArray(val) ? val.map(mappingFn) : mappingFn(val); + + return acc; + }, {} as Args); + + const includedArgs = Object.entries(mappedArgs).reduce((acc, [key, val]) => { + const argType = targetedContext.argTypes[key] || {}; + if (includeConditionalArg(argType, mappedArgs, targetedContext.globals)) acc[key] = val; + return acc; + }, {} as Args); + + return { ...targetedContext, unmappedArgs, args: includedArgs }; +} diff --git a/code/lib/preview-api/src/modules/store/csf/testing-utils/index.ts b/code/lib/preview-api/src/modules/store/csf/testing-utils/index.ts index 1aa0763b13a9..40fcddcb6e35 100644 --- a/code/lib/preview-api/src/modules/store/csf/testing-utils/index.ts +++ b/code/lib/preview-api/src/modules/store/csf/testing-utils/index.ts @@ -15,7 +15,7 @@ import type { import { HooksContext } from '../../../addons'; import { composeConfigs } from '../composeConfigs'; -import { prepareStory } from '../prepareStory'; +import { prepareContext, prepareStory } from '../prepareStory'; import { normalizeStory } from '../normalizeStory'; import { normalizeComponentAnnotations } from '../normalizeComponentAnnotations'; import { getValuesFromArgTypes } from '../getValuesFromArgTypes'; @@ -81,7 +81,7 @@ export function composeStory = context: StoryContextForLoaders ) => Promise & { loaded: StoryContext['loaded'] }>; playFunction?: (context: StoryContext) => Promise | void; - prepareContext: (context: StoryContext) => StoryContext; }; export type PreparedMeta = Omit< diff --git a/code/renderers/html/src/docs/sourceDecorator.test.ts b/code/renderers/html/src/docs/sourceDecorator.test.ts index 84407727e8fe..22550ab8050f 100644 --- a/code/renderers/html/src/docs/sourceDecorator.test.ts +++ b/code/renderers/html/src/docs/sourceDecorator.test.ts @@ -15,6 +15,7 @@ expect.addSnapshotSerializer({ const tick = () => new Promise((r) => setTimeout(r, 0)); const makeContext = (name: string, parameters: any, args: any, extra?: object): StoryContext => + // @ts-expect-error haven't added unmapped args to StoryContext yet ({ id: `html-test--${name}`, kind: 'js-text', @@ -23,6 +24,7 @@ const makeContext = (name: string, parameters: any, args: any, extra?: object): componentId: '', title: '', story: '', + unmappedArgs: args, args, argTypes: {}, globals: {}, diff --git a/code/renderers/html/src/docs/sourceDecorator.ts b/code/renderers/html/src/docs/sourceDecorator.ts index 73680d2cf117..da1160fdc4f7 100644 --- a/code/renderers/html/src/docs/sourceDecorator.ts +++ b/code/renderers/html/src/docs/sourceDecorator.ts @@ -37,8 +37,8 @@ export function sourceDecorator(storyFn: PartialStoryFn, context: } } useEffect(() => { - const { id, args } = context; - if (source) addons.getChannel().emit(SNIPPET_RENDERED, { id, args, source }); + const { id, unmappedArgs } = context; + if (source) addons.getChannel().emit(SNIPPET_RENDERED, { id, args: unmappedArgs, source }); }); return story; diff --git a/code/renderers/react/src/docs/jsxDecorator.test.tsx b/code/renderers/react/src/docs/jsxDecorator.test.tsx index 0309b0ba62a6..63fe5fb217b5 100644 --- a/code/renderers/react/src/docs/jsxDecorator.test.tsx +++ b/code/renderers/react/src/docs/jsxDecorator.test.tsx @@ -183,6 +183,7 @@ const makeContext = (name: string, parameters: any, args: any, extra?: object): kind: 'js-text', name, parameters, + unmappedArgs: args, args, ...extra, }); diff --git a/code/renderers/react/src/docs/jsxDecorator.tsx b/code/renderers/react/src/docs/jsxDecorator.tsx index a74bcc19d2c4..d2df67445a17 100644 --- a/code/renderers/react/src/docs/jsxDecorator.tsx +++ b/code/renderers/react/src/docs/jsxDecorator.tsx @@ -175,11 +175,11 @@ export const jsxDecorator = ( useEffect(() => { if (!skip) { - const { id, args } = context; + const { id, unmappedArgs } = context; channel.emit(SNIPPET_RENDERED, { id, source: jsx, - args, + args: unmappedArgs, }); } }); diff --git a/code/renderers/svelte/src/docs/sourceDecorator.ts b/code/renderers/svelte/src/docs/sourceDecorator.ts index efc3852255b1..9f16caf80162 100644 --- a/code/renderers/svelte/src/docs/sourceDecorator.ts +++ b/code/renderers/svelte/src/docs/sourceDecorator.ts @@ -157,8 +157,8 @@ export const sourceDecorator = (storyFn: any, context: StoryContext) = useEffect(() => { if (!skip && source) { - const { id, args } = context; - channel.emit(SNIPPET_RENDERED, { id, args, source }); + const { id, unmappedArgs } = context; + channel.emit(SNIPPET_RENDERED, { id, args: unmappedArgs, source }); } }); diff --git a/code/renderers/vue/src/docs/sourceDecorator.ts b/code/renderers/vue/src/docs/sourceDecorator.ts index 8d64ffbb217b..24c4fe3c4ddb 100644 --- a/code/renderers/vue/src/docs/sourceDecorator.ts +++ b/code/renderers/vue/src/docs/sourceDecorator.ts @@ -54,10 +54,10 @@ export const sourceDecorator = (storyFn: any, context: StoryContext) => { // @ts-expect-error TS says it is called $vnode const code = vnodeToString(storyNode._vnode); - const { id, args } = context; + const { id, unmappedArgs } = context; channel.emit(SNIPPET_RENDERED, { id, - args, + args: unmappedArgs, source: ``, format: 'vue', }); diff --git a/code/renderers/vue3/src/docs/sourceDecorator.ts b/code/renderers/vue3/src/docs/sourceDecorator.ts index ac2536218a24..4331324b796a 100644 --- a/code/renderers/vue3/src/docs/sourceDecorator.ts +++ b/code/renderers/vue3/src/docs/sourceDecorator.ts @@ -289,8 +289,8 @@ export const sourceDecorator = (storyFn: any, context: StoryContext) = useEffect(() => { if (!skip && source) { - const { id, args } = context; - channel.emit(SNIPPET_RENDERED, { id, args, source, format: 'vue' }); + const { id, unmappedArgs } = context; + channel.emit(SNIPPET_RENDERED, { id, args: unmappedArgs, source, format: 'vue' }); } }); diff --git a/code/renderers/web-components/src/docs/sourceDecorator.test.ts b/code/renderers/web-components/src/docs/sourceDecorator.test.ts index 1f8b2141fb82..d09a4136c7f5 100644 --- a/code/renderers/web-components/src/docs/sourceDecorator.test.ts +++ b/code/renderers/web-components/src/docs/sourceDecorator.test.ts @@ -22,6 +22,7 @@ const makeContext = (name: string, parameters: any, args: any, extra?: Partial { - const { id, args } = context; - if (source) addons.getChannel().emit(SNIPPET_RENDERED, { id, source, args }); + const { id, unmappedArgs } = context; + if (source) addons.getChannel().emit(SNIPPET_RENDERED, { id, source, args: unmappedArgs }); }); if (!skipSourceRender(context)) { const container = window.document.createElement('div'); diff --git a/code/ui/blocks/src/blocks/Source.tsx b/code/ui/blocks/src/blocks/Source.tsx index 177caefc0aca..30b279d1e9af 100644 --- a/code/ui/blocks/src/blocks/Source.tsx +++ b/code/ui/blocks/src/blocks/Source.tsx @@ -19,7 +19,6 @@ import type { SourceContextProps, SourceItem } from './SourceContainer'; import { UNKNOWN_ARGS_HASH, argsHash, SourceContext } from './SourceContainer'; import { useStories } from './useStory'; -import { useArgsList } from './useArgs'; export enum SourceState { OPEN = 'open', @@ -186,8 +185,6 @@ export const useSourceProps = ( // You are allowed to use and unattached. } } - const argsFromStories = useArgsList(stories, docsContext); - if (!storiesFromIds.every(Boolean)) { return { error: SourceError.SOURCE_UNAVAILABLE, state: SourceState.NONE }; } @@ -204,12 +201,12 @@ export const useSourceProps = ( // In theory you can use a storyId from a different CSF file that hasn't loaded yet. if (!story) return ''; - // NOTE: args *does* have to be defined here due to the null check on story above - const [args] = argsFromStories[index] || []; const storyContext = docsContext.getStoryContext(story); // eslint-disable-next-line no-underscore-dangle - const argsForSource = props.__forceInitialArgs ? storyContext.initialArgs : args; + const argsForSource = props.__forceInitialArgs + ? storyContext.initialArgs + : storyContext.unmappedArgs; const source = getStorySource(story.id, argsForSource, sourceContext); if (index === 0) { diff --git a/code/ui/blocks/src/blocks/useArgs.ts b/code/ui/blocks/src/blocks/useArgs.ts index 76dc8a021ae9..bc95fd52c501 100644 --- a/code/ui/blocks/src/blocks/useArgs.ts +++ b/code/ui/blocks/src/blocks/useArgs.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from 'react'; -import type { Args, DocsContextProps, PreparedStory, Renderer } from '@storybook/types'; +import type { Args, DocsContextProps, PreparedStory } from '@storybook/types'; import { STORY_ARGS_UPDATED, UPDATE_STORY_ARGS, RESET_STORY_ARGS } from '@storybook/core-events'; export const useArgs = ( @@ -38,10 +38,3 @@ export const useArgsIfDefined = ( ); return story && [args, updateArgs, resetArgs]; }; - -export function useArgsList( - stories: (PreparedStory | void)[], - context: DocsContextProps -) { - return stories.map((story) => useArgsIfDefined(story, context)); -}