From c4230889b13058e8883b1d8e6974b02142d2eac7 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Fri, 25 Apr 2025 13:39:54 +0200 Subject: [PATCH 01/27] WIP --- .../fixes/addon-globals-api.test.ts | 690 ++++++++++++++++++ .../automigrate/fixes/addon-globals-api.ts | 300 ++++++++ .../src/automigrate/fixes/index.ts | 2 + 3 files changed, 992 insertions(+) create mode 100644 code/lib/cli-storybook/src/automigrate/fixes/addon-globals-api.test.ts create mode 100644 code/lib/cli-storybook/src/automigrate/fixes/addon-globals-api.ts diff --git a/code/lib/cli-storybook/src/automigrate/fixes/addon-globals-api.test.ts b/code/lib/cli-storybook/src/automigrate/fixes/addon-globals-api.test.ts new file mode 100644 index 000000000000..473a3ea653d9 --- /dev/null +++ b/code/lib/cli-storybook/src/automigrate/fixes/addon-globals-api.test.ts @@ -0,0 +1,690 @@ +import * as fsp from 'node:fs/promises'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import * as common from 'storybook/internal/common'; + +// Import common to mock +import dedent from 'ts-dedent'; + +import type { FixResult } from '../types'; +// Import FixResult type +import { addonGlobalsApi } from './addon-globals-api'; + +// Mock fs/promises and common utilities +vi.mock('node:fs/promises', async () => import('../../../../../__mocks__/fs/promises')); +vi.mock('storybook/internal/common', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + scanAndTransformFiles: vi.fn().mockResolvedValue([]), // Mock scanAndTransformFiles + }; +}); + +const previewConfigPath = join('.storybook', 'preview.js'); + +const check = async (previewContents: string) => { + vi.mocked(fsp as any).__setMockFiles({ + [previewConfigPath]: previewContents, + }); + return addonGlobalsApi.check({ + packageManager: {} as any, + configDir: '', + mainConfig: {} as any, + storybookVersion: '9.0.0', // Assume v9 for testing migrations + previewConfigPath, + }); +}; + +// Helper to run the migration for preview file and capture scanAndTransformFiles args +const runMigrationAndGetTransformFn = async (previewContents: string) => { + const result = await check(previewContents); + const mockWriteFile = vi.mocked(fsp.writeFile); + const mockScanAndTransform = vi.mocked(common.scanAndTransformFiles); + + let transformFn: ((filePath: string, content: string) => Promise) | undefined; + let transformOptions: any; + + if (result) { + await addonGlobalsApi.run?.({ + result, + dryRun: false, + packageManager: {} as any, // Add necessary mock properties + } as any); + + if (mockScanAndTransform.mock.calls.length > 0) { + transformFn = mockScanAndTransform.mock.calls[0][0].transformFn; + // Extract options passed to transformStoryFile from the closure + // This is a bit indirect, relying on the implementation detail + transformOptions = { + needsViewportMigration: result.needsViewportMigration, + needsBackgroundsMigration: result.needsBackgroundsMigration, + backgroundValues: result.backgroundsOptions?.values, + }; + } + } + + return { + previewFileContent: mockWriteFile.mock.calls[0]?.[1] as string | undefined, + transformFn, + transformOptions, + migrationResult: result, + }; +}; + +describe('addon-globals-api', () => { + afterEach(() => { + vi.clearAllMocks(); + vi.mocked(common.scanAndTransformFiles).mockClear(); + }); + + describe('check', () => { + it('should return null for empty config', async () => { + await expect(check(`export default { parameters: {} }`)).resolves.toBeFalsy(); + }); + + it('should detect viewport configuration', async () => { + const result = await check(` + export default { + parameters: { + viewport: { + viewports: { + mobile: { name: 'Mobile', width: '320px', height: '568px' }, + tablet: { name: 'Tablet', width: '768px', height: '1024px' } + }, + defaultViewport: 'mobile' + } + } + } + `); + + expect(result).toBeTruthy(); + expect(result?.needsViewportMigration).toBe(true); + expect(result?.needsBackgroundsMigration).toBe(false); + expect(result?.viewportsOptions?.defaultViewport).toBe('mobile'); + }); + + it('should detect backgrounds configuration', async () => { + const result = await check(` + export default { + parameters: { + backgrounds: { + values: [ + { name: 'Light', value: '#F8F8F8' }, + { name: 'Dark', value: '#333333' } + ], + default: 'Light', + disable: false + } + } + } + `); + + expect(result).toBeTruthy(); + expect(result?.needsViewportMigration).toBe(false); + expect(result?.needsBackgroundsMigration).toBe(true); + expect(result?.backgroundsOptions?.default).toBe('Light'); + }); + + it('should detect both viewport and backgrounds configuration', async () => { + const result = await check(` + export default { + parameters: { + viewport: { + viewports: { + mobile: { name: 'Mobile', width: '320px', height: '568px' }, + tablet: { name: 'Tablet', width: '768px', height: '1024px' } + }, + defaultViewport: 'tablet' + }, + backgrounds: { + values: [ + { name: 'Light', value: '#F8F8F8' }, + { name: 'Dark', value: '#333333' } + ], + default: 'Dark' + } + } + } + `); + + expect(result).toBeTruthy(); + expect(result?.needsViewportMigration).toBe(true); + expect(result?.needsBackgroundsMigration).toBe(true); + expect(result?.viewportsOptions?.defaultViewport).toBe('tablet'); + expect(result?.backgroundsOptions?.default).toBe('Dark'); + }); + + it('should detect both viewport and backgrounds configuration with dynamic values', async () => { + const result = await check(` + import { INITIAL_VIEWPORTS } from '@storybook/addon-viewport'; + + const backgroundValues = [ + { name: 'Light', value: '#F8F8F8' }, + { name: 'Dark', value: '#333333' } + ]; + + export default { + parameters: { + viewport: { + viewports: INITIAL_VIEWPORTS, + defaultViewport: 'tablet' + }, + backgrounds: { + values: backgroundValues, + default: 'Dark' + } + } + } + `); + + expect(result).toBeTruthy(); + expect(result?.needsViewportMigration).toBe(true); + expect(result?.needsBackgroundsMigration).toBe(true); + expect(result?.viewportsOptions?.defaultViewport).toBe('tablet'); + expect(result?.backgroundsOptions?.default).toBe('Dark'); + }); + + it('should not detect configurations already using globals API', async () => { + const result = await check(` + export default { + parameters: { + viewport: { + options: { + mobile: { name: 'Mobile', width: '320px', height: '568px' }, + tablet: { name: 'Tablet', width: '768px', height: '1024px' } + } + }, + backgrounds: { + options: { + light: { name: 'Light', value: '#F8F8F8' }, + dark: { name: 'Dark', value: '#333333' } + } + } + }, + initialGlobals: { + viewport: { value: 'mobile', isRotated: false }, + backgrounds: { value: 'dark' } + } + } + `); + + // Since there's no defaultViewport or default properties, it should return null (nothing to migrate) + expect(result?.needsViewportMigration).toBeFalsy(); + expect(result?.needsBackgroundsMigration).toBeFalsy(); + }); + }); + + describe('run - preview file', () => { + it('should migrate viewport configuration correctly', async () => { + const { previewFileContent } = await runMigrationAndGetTransformFn(` + export default { + parameters: { + viewport: { + viewports: { + mobile: { name: 'Mobile', width: '320px', height: '568px' }, + tablet: { name: 'Tablet', width: '768px', height: '1024px' } + }, + defaultViewport: 'mobile' + } + } + } + `); + + expect(previewFileContent).toMatchInlineSnapshot(` + " + export default { + parameters: { + viewport: { + options: { + mobile: { name: 'Mobile', width: '320px', height: '568px' }, + tablet: { name: 'Tablet', width: '768px', height: '1024px' } + } + } + }, + + initialGlobals: { + viewport: { + value: 'mobile', + isRotated: false + } + } + }; + " + `); + }); + + it('should migrate backgrounds configuration correctly', async () => { + const { previewFileContent } = await runMigrationAndGetTransformFn(` + export default { + parameters: { + backgrounds: { + values: [ + { name: 'Light', value: '#F8F8F8' }, + { name: 'Dark', value: '#333333' } + ], + default: 'Light' + } + } + } + `); + + expect(previewFileContent).toMatchInlineSnapshot(` + " + export default { + parameters: { + backgrounds: { + options: { + light: { name: 'Light', value: '#F8F8F8' }, + dark: { name: 'Dark', value: '#333333' } + } + } + }, + + initialGlobals: { + backgrounds: { + value: 'light' + } + } + }; + " + `); + }); + + it('should rename backgrounds disable property to disabled', async () => { + const { previewFileContent } = await runMigrationAndGetTransformFn(` + export default { + parameters: { + backgrounds: { + values: [ + { name: 'Light', value: '#F8F8F8' } + ], + disable: true + } + } + } + `); + + // Verify the transformation results + expect(previewFileContent).toMatchInlineSnapshot(` + " + export default { + parameters: { + backgrounds: { + options: { + light: { name: 'Light', value: '#F8F8F8' } + }, + disabled: true + } + } + } + " + `); + }); + + it('should migrate both viewport and backgrounds configurations', async () => { + const { previewFileContent } = await runMigrationAndGetTransformFn(` + export default { + parameters: { + viewport: { + viewports: { + mobile: { name: 'Mobile', width: '320px', height: '568px' } + }, + defaultViewport: 'mobile' + }, + backgrounds: { + values: [ + { name: 'Light', value: '#F8F8F8' }, + { name: 'Dark', value: '#F8F8F8' } + ], + default: 'Light' + } + } + } + `); + + expect(previewFileContent).toMatchInlineSnapshot(` + " + export default { + parameters: { + viewport: { + options: { + mobile: { name: 'Mobile', width: '320px', height: '568px' } + } + }, + backgrounds: { + options: { + light: { name: 'Light', value: '#F8F8F8' }, + dark: { name: 'Dark', value: '#F8F8F8' } + } + } + }, + + initialGlobals: { + viewport: { + value: 'mobile', + isRotated: false + }, + + backgrounds: { + value: 'light' + } + } + }; + " + `); + }); + + it('should correctly handle partial configurations', async () => { + const { previewFileContent } = await runMigrationAndGetTransformFn(dedent` + export default { + parameters: { + viewport: { + viewports: { + mobile: { name: 'Mobile', width: '320px', height: '568px' } + } + }, + backgrounds: { + values: [ + { name: 'Light', value: '#F8F8F8' } + ] + } + } + } + `); + + // Verify the transformation results + expect(previewFileContent).toMatchInlineSnapshot(dedent` + "export default { + parameters: { + viewport: { + options: { + mobile: { name: 'Mobile', width: '320px', height: '568px' } + } + }, + backgrounds: { + options: { + light: { name: 'Light', value: '#F8F8F8' } + } + } + } + }" + `); + }); + + it('should migrate dynamic values correctly', async () => { + const { previewFileContent } = await runMigrationAndGetTransformFn(dedent` + import { INITIAL_VIEWPORTS } from '@storybook/addon-viewport'; + + export default { + parameters: { + viewport: { + viewports: INITIAL_VIEWPORTS, + defaultViewport: 'tablet' + }, + backgrounds: { + values: [ + { name: 'Light', value: '#F8F8F8' }, + { name: 'Dark', value: '#333333' } + ], + default: 'Light' + } + } + } + `); + + expect(previewFileContent).toMatchInlineSnapshot(` + "import { INITIAL_VIEWPORTS } from '@storybook/addon-viewport'; + + export default { + parameters: { + viewport: { + options: INITIAL_VIEWPORTS + }, + backgrounds: { + options: { + light: { name: 'Light', value: '#F8F8F8' }, + dark: { name: 'Dark', value: '#333333' } + } + } + }, + + initialGlobals: { + viewport: { + value: 'tablet', + isRotated: false + }, + + backgrounds: { + value: 'light' + } + } + };" + `); + }); + }); + + describe('run - story files', () => { + const defaultPreview = dedent` + export default { + parameters: { + viewport: { + viewports: { mobile: {}, tablet: {} }, + defaultViewport: 'mobile' + }, + backgrounds: { + values: [ + { name: 'Light', value: '#F8F8F8' }, + { name: 'Dark', value: '#333333' }, + { name: 'Tweet', value: '#00aced' } + ], + default: 'Light', + disable: false + } + } + } + `; + + it('should migrate parameters.backgrounds.default to globals.backgrounds', async () => { + const { transformFn } = await runMigrationAndGetTransformFn(defaultPreview); + const storyContent = dedent` + import Button from './Button'; + export default { component: Button }; + export const Default = { + parameters: { + backgrounds: { default: 'Dark' } + } + }; + `; + + const expectedContent = dedent` + import Button from './Button'; + export default { component: Button }; + export const Default = { + globals: { + backgrounds: { value: "dark" } + } + }; + `; + expect(transformFn).toBeDefined(); + await expect(transformFn!('story.js', storyContent)).resolves.toBe(expectedContent); + }); + + it('should migrate parameters.backgrounds.disable: true to disabled: true', async () => { + const { transformFn } = await runMigrationAndGetTransformFn(defaultPreview); + const storyContent = dedent` + import Button from './Button'; + export default { component: Button }; + export const Disabled = { + parameters: { + backgrounds: { disable: true } + } + }; + `; + const expectedContent = dedent` + import Button from './Button'; + export default { component: Button }; + export const Disabled = { + parameters: { + backgrounds: { disabled: true } + } + }; + `; + expect(transformFn).toBeDefined(); + await expect(transformFn!('story.js', storyContent)).resolves.toBe(expectedContent); + }); + + it('should remove parameters.backgrounds.disable: false', async () => { + const { transformFn } = await runMigrationAndGetTransformFn(defaultPreview); + const storyContent = dedent` + import Button from './Button'; + export default { component: Button }; + export const Disabled = { + parameters: { + backgrounds: { disable: false } // This should be removed + } + }; + `; + // parameters.backgrounds becomes empty and should be removed + const expectedContent = dedent` + import Button from './Button'; + export default { component: Button }; + export const Disabled = {}; + `; + expect(transformFn).toBeDefined(); + await expect(transformFn!('story.js', storyContent)).resolves.toBe(expectedContent); + }); + + it('should migrate parameters.viewport.defaultViewport to globals.viewport', async () => { + const { transformFn } = await runMigrationAndGetTransformFn(defaultPreview); + const storyContent = dedent` + import Button from './Button'; + export default { component: Button }; + export const MobileOnly = { + parameters: { + viewport: { defaultViewport: 'mobile' } + } + }; + `; + const expectedContent = dedent` + import Button from './Button'; + export default { component: Button }; + export const MobileOnly = { + globals: { + viewport: { + value: "mobile", + isRotated: false + } + } + }; + `; + expect(transformFn).toBeDefined(); + await expect(transformFn!('story.js', storyContent)).resolves.toBe(expectedContent); + }); + + it('should migrate both viewport and backgrounds in the same story', async () => { + const { transformFn } = await runMigrationAndGetTransformFn(defaultPreview); + const storyContent = dedent` + import Button from './Button'; + export default { component: Button }; + export const DarkMobile = { + parameters: { + viewport: { defaultViewport: 'mobile' }, + backgrounds: { default: 'Dark' } + } + }; + `; + const expectedContent = dedent` + import Button from './Button'; + export default { component: Button }; + export const DarkMobile = { + globals: { + viewport: { + value: "mobile", + isRotated: false + }, + backgrounds: { value: "dark" } + } + }; + `; + expect(transformFn).toBeDefined(); + await expect(transformFn!('story.js', storyContent)).resolves.toBe(expectedContent); + }); + + it('should handle migration in meta (export default)', async () => { + const { transformFn } = await runMigrationAndGetTransformFn(defaultPreview); + const storyContent = dedent` + import Button from './Button'; + export default { + component: Button, + parameters: { + backgrounds: { default: 'Tweet' } + } + }; + export const Default = {}; + `; + const expectedContent = dedent` + import Button from './Button'; + export default { + component: Button, + globals: { + backgrounds: { value: "tweet" } + } + }; + export const Default = {}; + `; + expect(transformFn).toBeDefined(); + await expect(transformFn!('story.js', storyContent)).resolves.toBe(expectedContent); + }); + + it('should return null if no changes are needed', async () => { + const { transformFn } = await runMigrationAndGetTransformFn(defaultPreview); + const storyContent = dedent` + import Button from './Button'; + export default { component: Button }; + export const NoParams = {}; + export const ExistingGlobals = { globals: { backgrounds: { value: 'dark' } } }; + export const ExistingDisabled = { parameters: { backgrounds: { disabled: true } } }; + `; + expect(transformFn).toBeDefined(); + await expect(transformFn!('story.js', storyContent)).resolves.toBeNull(); + }); + + it('should remove empty parameters/backgrounds/viewport objects after migration', async () => { + const { transformFn } = await runMigrationAndGetTransformFn(defaultPreview); + const storyContent = dedent` + import Button from './Button'; + export default { component: Button }; + export const TestStory = { + parameters: { + otherParam: true, + backgrounds: { default: 'Dark' }, // This will move + viewport: { defaultViewport: 'tablet' } // This will move + } + }; + `; + // parameters.backgrounds and parameters.viewport become empty and are removed + // parameters still has otherParam, so it remains + const expectedContent = dedent` + import Button from './Button'; + export default { component: Button }; + export const TestStory = { + parameters: { + otherParam: true + }, + + globals: { + backgrounds: { value: "dark" }, + viewport: { + value: "tablet", + isRotated: false + } + } + }; + `; + expect(transformFn).toBeDefined(); + await expect(transformFn!('story.js', storyContent)).resolves.toBe(expectedContent); + }); + }); +}); diff --git a/code/lib/cli-storybook/src/automigrate/fixes/addon-globals-api.ts b/code/lib/cli-storybook/src/automigrate/fixes/addon-globals-api.ts new file mode 100644 index 000000000000..422519f079b7 --- /dev/null +++ b/code/lib/cli-storybook/src/automigrate/fixes/addon-globals-api.ts @@ -0,0 +1,300 @@ +import { readFile, writeFile } from 'node:fs/promises'; + +import { types as t } from 'storybook/internal/babel'; +import type { ConfigFile } from 'storybook/internal/csf-tools'; +import { formatConfig, loadConfig } from 'storybook/internal/csf-tools'; + +import type { ArrayExpression, Expression, ObjectExpression } from '@babel/types'; +import picocolors from 'picocolors'; +import { dedent } from 'ts-dedent'; + +import type { Fix } from '../types'; + +const MIGRATION = + 'https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#viewportbackgrounds-addon-synchronized-configuration-and-globals-usage'; + +interface AddonGlobalsApiOptions { + previewConfig: ConfigFile; + previewConfigPath: string; + needsViewportMigration: boolean; + needsBackgroundsMigration: boolean; + viewportsOptions: + | { + defaultViewport?: string; + viewports?: Expression; + } + | undefined; + backgroundsOptions: + | { + default?: string; + values?: Expression; + disable?: boolean; + } + | undefined; +} + +/** + * Migrate viewport and backgrounds addons to use the new globals API in Storybook 9 + * + * - Migrate viewports to use options and initialGlobals + * - Migrate backgrounds to use options and initialGlobals + */ +export const addonGlobalsApi: Fix = { + id: 'addon-globals-api', + versionRange: ['<9.0.0', '^9.0.0-0 || ^9.0.0'], + + async check({ previewConfigPath }) { + if (!previewConfigPath) { + return null; + } + + const previewConfig = loadConfig((await readFile(previewConfigPath)).toString()).parse(); + + const getFieldNode = previewConfig.getFieldNode.bind(previewConfig); + const getFieldValue = previewConfig.getFieldValue.bind(previewConfig); + + // Reusable function to check addon migration status + const checkAddonMigration = (addonName: 'viewport' | 'backgrounds') => { + const paramPath = ['parameters', addonName]; + const addonParams = getFieldNode(paramPath) as ObjectExpression | undefined; + + if (!addonParams) { + return { needsMigration: false }; + } + + const hasOptions = getFieldNode([...paramPath, 'options']) !== undefined; + + // Define fields to check based on addon type + const fieldsToCheck = + addonName === 'viewport' + ? ['viewports', 'defaultViewport'] + : ['values', 'default', 'disable']; + + // Check if any old format fields exist + const hasOldFormat = fieldsToCheck.some( + (field) => getFieldNode([...paramPath, field]) !== undefined + ); + + // Only migrate if using old format and not already migrated + const needsMigration = hasOldFormat && !hasOptions; + + // Collect relevant options from old format + const options: Record = {}; + + if (needsMigration) { + fieldsToCheck.forEach((field) => { + const value = + (addonName === 'viewport' && field === 'viewports') || + (addonName === 'backgrounds' && field === 'values') + ? getFieldNode([...paramPath, field]) + : getFieldValue([...paramPath, field]); + + if (value !== undefined) { + // Convert field names if necessary (maintaining the expected output structure) + const optionKey = addonName === 'viewport' ? field : field; + options[optionKey] = value; + } + }); + } + + return { needsMigration, options }; + }; + + // Check migration status for both addons + const viewportMigration = checkAddonMigration('viewport'); + const backgroundsMigration = checkAddonMigration('backgrounds'); + + // Return null if there's nothing to migrate + if (!viewportMigration.needsMigration && !backgroundsMigration.needsMigration) { + return null; + } + + return { + previewConfig, + previewConfigPath, + needsViewportMigration: viewportMigration.needsMigration, + needsBackgroundsMigration: backgroundsMigration.needsMigration, + viewportsOptions: viewportMigration.options, + backgroundsOptions: backgroundsMigration.options, + }; + }, + + prompt({ needsViewportMigration, needsBackgroundsMigration }) { + return dedent` + We've detected that you're using the ${needsViewportMigration && needsBackgroundsMigration ? 'viewport and backgrounds addons' : needsViewportMigration ? 'viewport addon' : 'backgrounds addon'} with the deprecated configuration API. + + In Storybook 9, ${needsViewportMigration && needsBackgroundsMigration ? 'these addons have' : 'this addon has'} been updated to use the new globals API which ensures a consistent experience while navigating between stories. + + We'll update your configuration to use the new API: + ${needsViewportMigration ? `- ${picocolors.yellow('viewports')} → ${picocolors.yellow('options')} and ${picocolors.yellow('defaultViewport')} → ${picocolors.yellow('initialGlobals.viewport')}\n` : ''}${needsBackgroundsMigration ? `- ${picocolors.yellow('values')} → ${picocolors.yellow('options')} and ${picocolors.yellow('default')} → ${picocolors.yellow('initialGlobals.backgrounds')}\n` : ''} + + Learn more: ${picocolors.cyan(MIGRATION)} + `; + }, + + async run({ dryRun, result }) { + const { + previewConfig, + needsViewportMigration, + needsBackgroundsMigration, + viewportsOptions, + backgroundsOptions, + } = result; + + const getFieldNode = previewConfig.getFieldNode.bind(previewConfig); + + if (needsViewportMigration) { + // Get the viewport parameter object + const viewports = getFieldNode(['parameters', 'viewport', 'viewports']) as ObjectExpression; + + if (viewportsOptions?.viewports) { + // Remove the old viewports property + previewConfig.removeField(['parameters', 'viewport', 'viewports']); + addProperty( + getFieldNode(['parameters', 'viewport']) as ObjectExpression, + 'options', + viewports + ); + } + + // If defaultViewport exists, create initialGlobals.viewport + if (viewportsOptions?.defaultViewport) { + // Remove the old defaultViewport property + const viewportNode = getFieldNode(['parameters', 'viewport']); + removeProperty(viewportNode as ObjectExpression, 'defaultViewport'); + + previewConfig.setFieldValue( + ['initialGlobals', 'viewport', 'value'], + viewportsOptions.defaultViewport + ); + previewConfig.setFieldValue(['initialGlobals', 'viewport', 'isRotated'], false); + } + } + + if (needsBackgroundsMigration) { + if (backgroundsOptions?.values) { + // Transform values array to options object + const optionsObject = transformValuesToOptions( + backgroundsOptions.values as ArrayExpression + ); + + // Remove the old values property + previewConfig.removeField(['parameters', 'backgrounds', 'values']); + addProperty( + getFieldNode(['parameters', 'backgrounds']) as ObjectExpression, + 'options', + optionsObject + ); + } + + // If default exists, create initialGlobals.backgrounds + if (backgroundsOptions?.default) { + // Remove the old default property + removeProperty(getFieldNode(['parameters', 'backgrounds']) as ObjectExpression, 'default'); + + previewConfig.setFieldValue( + ['initialGlobals', 'backgrounds', 'value'], + getKeyFromName(backgroundsOptions.values as ArrayExpression, backgroundsOptions.default) + ); + } + + // If disable exists, rename to disabled + if (backgroundsOptions?.disable === true) { + // Remove the old disable property + removeProperty(getFieldNode(['parameters', 'backgrounds']) as ObjectExpression, 'disable'); + + addProperty( + getFieldNode(['parameters', 'backgrounds']) as ObjectExpression, + 'disabled', + t.booleanLiteral(true) + ); + } + } + + // Write the updated config back to the file + if (!dryRun) { + await writeFile(result.previewConfigPath, formatConfig(previewConfig)); + } + }, +}; + +// Helper functions + +function getObjectProperty(obj: ObjectExpression, propertyName: string): Expression | undefined { + if (!obj || !obj.properties) { + return undefined; + } + + const property = obj.properties.find( + (prop) => + t.isObjectProperty(prop) && + ((t.isIdentifier(prop.key) && prop.key.name === propertyName) || + (t.isStringLiteral(prop.key) && prop.key.value === propertyName)) + ) as t.ObjectProperty; + + return property?.value as Expression; +} + +function removeProperty(obj: ObjectExpression, propertyName: string): void { + if (!obj || !obj.properties) { + return; + } + + const index = obj.properties.findIndex( + (prop) => + t.isObjectProperty(prop) && + ((t.isIdentifier(prop.key) && prop.key.name === propertyName) || + (t.isStringLiteral(prop.key) && prop.key.value === propertyName)) + ); + + if (index !== -1) { + obj.properties.splice(index, 1); + } +} + +function addProperty(obj: ObjectExpression, propertyName: string, value: Expression): void { + if (!obj) { + return; + } + + obj.properties.push(t.objectProperty(t.identifier(propertyName), value)); +} + +function transformValuesToOptions(valuesArray: ArrayExpression): Expression { + // Transform [{ name: 'Light', value: '#FFF' }] to { light: { name: 'Light', value: '#FFF' } } + const optionsObject = t.objectExpression([]); + + if (valuesArray && t.isArrayExpression(valuesArray) && valuesArray.elements) { + valuesArray.elements.forEach((element) => { + if (t.isObjectExpression(element)) { + const nameProperty = getObjectProperty(element, 'name'); + + if (t.isStringLiteral(nameProperty)) { + const key = nameProperty.value.toLowerCase().replace(/\s+/g, '_'); + + optionsObject.properties.push(t.objectProperty(t.identifier(key), element)); + } + } + }); + } + + return optionsObject; +} + +function getKeyFromName(valuesArray: ArrayExpression, name: string): string { + // Generate a key from a name in the values array + if (valuesArray && t.isArrayExpression(valuesArray) && valuesArray.elements) { + for (const element of valuesArray.elements) { + if (t.isObjectExpression(element)) { + const nameProperty = getObjectProperty(element, 'name'); + + if (t.isStringLiteral(nameProperty) && nameProperty.value === name) { + return name.toLowerCase().replace(/\s+/g, '_'); + } + } + } + } + + // If not found, generate a key from the name + return name.toLowerCase().replace(/\s+/g, '_'); +} diff --git a/code/lib/cli-storybook/src/automigrate/fixes/index.ts b/code/lib/cli-storybook/src/automigrate/fixes/index.ts index 66278a0cb2e1..8a577751d0f9 100644 --- a/code/lib/cli-storybook/src/automigrate/fixes/index.ts +++ b/code/lib/cli-storybook/src/automigrate/fixes/index.ts @@ -4,6 +4,7 @@ import { addonA11yAddonTest } from './addon-a11y-addon-test'; import { addonA11yParameters } from './addon-a11y-parameters'; import { addonEssentialsRemoveDocs } from './addon-essentials-remove-docs'; import { addonExperimentalTest } from './addon-experimental-test'; +import { addonGlobalsApi } from './addon-globals-api'; import { addonMdxGfmRemove } from './addon-mdx-gfm-remove'; import { addonStorysourceRemove } from './addon-storysource-remove'; import { consolidatedImports } from './consolidated-imports'; @@ -24,6 +25,7 @@ export const allFixes: Fix[] = [ addonStorysourceRemove, upgradeStorybookRelatedDependencies, initialGlobals, + addonGlobalsApi, addonA11yAddonTest, consolidatedImports, addonExperimentalTest, From 91edda35918b6eb122211834e806cfe5ce84d89c Mon Sep 17 00:00:00 2001 From: Mateusz Kadlubowski Date: Tue, 6 May 2025 22:23:56 +0800 Subject: [PATCH 02/27] =?UTF-8?q?remove=20ancient=20code=20=F0=9F=98=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- code/renderers/svelte/src/types.ts | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/code/renderers/svelte/src/types.ts b/code/renderers/svelte/src/types.ts index 503e47002b57..66f361451826 100644 --- a/code/renderers/svelte/src/types.ts +++ b/code/renderers/svelte/src/types.ts @@ -8,25 +8,6 @@ import type { ComponentConstructorOptions, ComponentEvents, SvelteComponent } fr export type StoryContext = StoryContextBase; -export interface ShowErrorArgs { - title: string; - description: string; -} - -export interface MountViewArgs { - Component: any; - target: any; - props: MountProps; - on: any; - Wrapper: any; - WrapperData: any; -} - -interface MountProps { - rounded: boolean; - text: string; -} - type ComponentType< Props extends Record = any, Events extends Record = any, From 7358b5b21cc984a42382a0f18e70fa28b77ac9c7 Mon Sep 17 00:00:00 2001 From: Mateusz Kadlubowski Date: Tue, 6 May 2025 22:24:46 +0800 Subject: [PATCH 03/27] simplify to use modern `Component` type from svelte --- code/renderers/svelte/src/types.ts | 41 +++++++------------------- code/renderers/svelte/src/typings.d.ts | 4 +-- 2 files changed, 12 insertions(+), 33 deletions(-) diff --git a/code/renderers/svelte/src/types.ts b/code/renderers/svelte/src/types.ts index 66f361451826..ebfe203bb165 100644 --- a/code/renderers/svelte/src/types.ts +++ b/code/renderers/svelte/src/types.ts @@ -4,51 +4,30 @@ import type { WebRenderer, } from 'storybook/internal/types'; -import type { ComponentConstructorOptions, ComponentEvents, SvelteComponent } from 'svelte'; +import type { Component, ComponentProps } from 'svelte'; export type StoryContext = StoryContextBase; -type ComponentType< - Props extends Record = any, - Events extends Record = any, -> = new (options: ComponentConstructorOptions) => { - [P in keyof SvelteComponent as P extends `$$${string}` ? never : P]: SvelteComponent< - Props, - Events - >[P]; -}; - -export type Svelte5ComponentType = any> = - typeof import('svelte') extends { mount: any } - ? // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore svelte.Component doesn't exist in Svelte 4 - import('svelte').Component - : never; - -export interface SvelteRenderer +export interface SvelteRenderer = Component> extends WebRenderer { - component: - | ComponentType ? this['T'] : any> - | Svelte5ComponentType ? this['T'] : any>; + component: Component ? this['T'] : any>; storyResult: this['T'] extends Record - ? SvelteStoryResult : {}> + ? SvelteStoryResult : SvelteStoryResult; mount: ( - Component?: ComponentType | Svelte5ComponentType, + Component?: C, // TODO add proper typesafety - options?: Record & { props: Record } + options?: Record & { props: ComponentProps } ) => Promise; } export interface SvelteStoryResult< Props extends Record = any, - Events extends Record = any, + Exports extends Record = any, + Bindings extends keyof Props | '' = string, > { - Component?: ComponentType | Svelte5ComponentType; - on?: Record extends Events - ? Record void> - : { [K in keyof Events as string extends K ? never : K]?: (event: Events[K]) => void }; + Component?: Component; props?: Props; - decorator?: ComponentType | Svelte5ComponentType; + decorator?: Component; } diff --git a/code/renderers/svelte/src/typings.d.ts b/code/renderers/svelte/src/typings.d.ts index 2e00d983aa34..1195d9bf273a 100644 --- a/code/renderers/svelte/src/typings.d.ts +++ b/code/renderers/svelte/src/typings.d.ts @@ -2,8 +2,8 @@ declare var STORYBOOK_ENV: 'svelte'; declare var LOGLEVEL: 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'silent' | undefined; declare module '*.svelte' { - import type { ComponentType } from 'svelte'; + import type { Component } from 'svelte'; - const component: ComponentType; + const component: Component; export default component; } From a936c264ff2f3be7a55f5dbf50ca0fb8135a1950 Mon Sep 17 00:00:00 2001 From: Mateusz Kadlubowski Date: Tue, 6 May 2025 22:30:49 +0800 Subject: [PATCH 04/27] delete old svelte 4 component from testing --- code/renderers/svelte/src/__test__/Button.svelte | 15 +++++++++------ .../renderers/svelte/src/__test__/ButtonV5.svelte | 15 --------------- 2 files changed, 9 insertions(+), 21 deletions(-) delete mode 100644 code/renderers/svelte/src/__test__/ButtonV5.svelte diff --git a/code/renderers/svelte/src/__test__/Button.svelte b/code/renderers/svelte/src/__test__/Button.svelte index b7fd6e8e325c..f2f172f8089f 100644 --- a/code/renderers/svelte/src/__test__/Button.svelte +++ b/code/renderers/svelte/src/__test__/Button.svelte @@ -1,12 +1,15 @@ - diff --git a/code/renderers/svelte/src/__test__/ButtonV5.svelte b/code/renderers/svelte/src/__test__/ButtonV5.svelte deleted file mode 100644 index f2f172f8089f..000000000000 --- a/code/renderers/svelte/src/__test__/ButtonV5.svelte +++ /dev/null @@ -1,15 +0,0 @@ - - - From 835e6c44ec47575148d5ea4eacd24086be95b3db Mon Sep 17 00:00:00 2001 From: Mateusz Kadlubowski Date: Tue, 6 May 2025 23:54:52 +0800 Subject: [PATCH 05/27] remove `expect-type` - is bundled into `vitest` --- code/renderers/svelte/package.json | 1 - code/yarn.lock | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/code/renderers/svelte/package.json b/code/renderers/svelte/package.json index 4d8379a10fda..10820fb65e26 100644 --- a/code/renderers/svelte/package.json +++ b/code/renderers/svelte/package.json @@ -61,7 +61,6 @@ "devDependencies": { "@sveltejs/vite-plugin-svelte": "^5.0.3", "@testing-library/svelte": "^5.2.4", - "expect-type": "^1.1.0", "svelte": "^5.20.5", "svelte-check": "^4.1.4", "sveltedoc-parser": "^4.2.1", diff --git a/code/yarn.lock b/code/yarn.lock index bcf38043f23d..36f8ac61a7cb 100644 --- a/code/yarn.lock +++ b/code/yarn.lock @@ -6862,7 +6862,6 @@ __metadata: dependencies: "@sveltejs/vite-plugin-svelte": "npm:^5.0.3" "@testing-library/svelte": "npm:^5.2.4" - expect-type: "npm:^1.1.0" svelte: "npm:^5.20.5" svelte-check: "npm:^4.1.4" sveltedoc-parser: "npm:^4.2.1" @@ -14386,7 +14385,7 @@ __metadata: languageName: node linkType: hard -"expect-type@npm:^1.1.0, expect-type@npm:^1.2.1": +"expect-type@npm:^1.2.1": version: 1.2.1 resolution: "expect-type@npm:1.2.1" checksum: 10c0/b775c9adab3c190dd0d398c722531726cdd6022849b4adba19dceab58dda7e000a7c6c872408cd73d665baa20d381eca36af4f7b393a4ba60dd10232d1fb8898 From 99bbc59a2147495d3819c069d9cbe9b897c6ed37 Mon Sep 17 00:00:00 2001 From: Mateusz Kadlubowski Date: Tue, 6 May 2025 23:55:37 +0800 Subject: [PATCH 06/27] refactor svelte renderer public-types - simplifcation by using `Component` --- code/renderers/svelte/src/public-types.ts | 42 ++++++++--------------- 1 file changed, 14 insertions(+), 28 deletions(-) diff --git a/code/renderers/svelte/src/public-types.ts b/code/renderers/svelte/src/public-types.ts index 87b9d58aa0c0..c7414beb560b 100644 --- a/code/renderers/svelte/src/public-types.ts +++ b/code/renderers/svelte/src/public-types.ts @@ -12,10 +12,10 @@ import type { StrictArgs, } from 'storybook/internal/types'; -import type { ComponentProps, ComponentType, SvelteComponent } from 'svelte'; +import type { Component, ComponentProps } from 'svelte'; import type { SetOptional, Simplify } from 'type-fest'; -import type { Svelte5ComponentType, SvelteRenderer } from './types'; +import type { SvelteRenderer } from './types'; export type { Args, ArgTypes, Parameters, StrictArgs } from 'storybook/internal/types'; @@ -24,22 +24,20 @@ export type { Args, ArgTypes, Parameters, StrictArgs } from 'storybook/internal/ * * @see [Default export](https://storybook.js.org/docs/api/csf#default-export) */ -export type Meta = CmpOrArgs extends - | SvelteComponent - | Svelte5ComponentType - ? ComponentAnnotations, Props> - : ComponentAnnotations; +export type Meta = + CmpOrArgs extends Component + ? ComponentAnnotations, Props> + : ComponentAnnotations; /** * Story function that represents a CSFv2 component example. * * @see [Named Story exports](https://storybook.js.org/docs/api/csf#named-story-exports) */ -export type StoryFn = TCmpOrArgs extends - | SvelteComponent - | Svelte5ComponentType - ? AnnotatedStoryFn - : AnnotatedStoryFn; +export type StoryFn = + TCmpOrArgs extends Component + ? AnnotatedStoryFn + : AnnotatedStoryFn; /** * Story object that represents a CSFv3 component example. @@ -48,32 +46,20 @@ export type StoryFn = TCmpOrArgs extends */ export type StoryObj = MetaOrCmpOrArgs extends { render?: ArgsStoryFn; - component: infer Comp; // We cannot use "extends ComponentType | Svelte5ComponentType" here, because TypeScript for some reason then refuses to ever enter the true branch + component: infer Comp; // We cannot use "extends Component" here, because TypeScript for some reason then refuses to ever enter the true branch args?: infer DefaultArgs; } ? Simplify< - ComponentProps< - Comp extends ComponentType - ? Component - : Comp extends Svelte5ComponentType - ? Comp - : never - > & + ComponentProps ? Comp : never> & ArgsFromMeta > extends infer TArgs ? StoryAnnotations< - SvelteRenderer< - Comp extends ComponentType - ? Component - : Comp extends Svelte5ComponentType - ? Comp - : never - >, + SvelteRenderer ? Comp : never>, TArgs, SetOptional> > : never - : MetaOrCmpOrArgs extends SvelteComponent | Svelte5ComponentType + : MetaOrCmpOrArgs extends Component ? StoryAnnotations, ComponentProps> : StoryAnnotations; From 08d17970ca90a612d4a0b124b46a8d2ee31facd5 Mon Sep 17 00:00:00 2001 From: Mateusz Kadlubowski Date: Tue, 6 May 2025 23:56:10 +0800 Subject: [PATCH 07/27] update test types --- .../renderers/svelte/src/public-types.test.ts | 182 ++++-------------- 1 file changed, 36 insertions(+), 146 deletions(-) diff --git a/code/renderers/svelte/src/public-types.test.ts b/code/renderers/svelte/src/public-types.test.ts index 154065beec2f..20f4d23140dd 100644 --- a/code/renderers/svelte/src/public-types.test.ts +++ b/code/renderers/svelte/src/public-types.test.ts @@ -1,5 +1,5 @@ // this file tests Typescript types that's why there are no assertions -import { describe, it } from 'vitest'; +import { describe, expectTypeOf, it } from 'vitest'; import { satisfies } from 'storybook/internal/common'; import type { @@ -9,32 +9,23 @@ import type { StoryAnnotations, } from 'storybook/internal/types'; -import { expectTypeOf } from 'expect-type'; -import { type Component, type ComponentProps, SvelteComponent } from 'svelte'; +import type { Component, ComponentProps } from 'svelte'; import Button from './__test__/Button.svelte'; -import ButtonV5 from './__test__/ButtonV5.svelte'; import Decorator2 from './__test__/Decorator2.svelte'; import Decorator1 from './__test__/Decorator.svelte'; import type { Decorator, Meta, StoryObj } from './public-types'; import type { SvelteRenderer } from './types'; -type SvelteStory< - Comp extends SvelteComponent | Component, +type SvelteStory, Args, RequiredArgs> = StoryAnnotations< + SvelteRenderer, Args, - RequiredArgs, -> = StoryAnnotations, Args, RequiredArgs>; - -// The imported Svelte component in Svelte 5 has an isomorphic type (both function and class). -// In order to test how it would look like for real Svelte 4 components, we need to create the class type manually. -declare class ButtonV4 extends SvelteComponent<{ - disabled: boolean; - label: string; -}> {} + RequiredArgs +>; describe('Meta', () => { it('Generic parameter of Meta can be a component', () => { - const meta: Meta