diff --git a/CHANGELOG.md b/CHANGELOG.md index 6493743dae6d..a0388a028a51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## 10.2.7 + +- CSF: Fix cross-file story imports in csf-factories codemod - [#33723](https://github.com/storybookjs/storybook/pull/33723), thanks @yatishgoel! +- Core: Fix rendering of View Transitions in Firefox - [#33651](https://github.com/storybookjs/storybook/pull/33651), thanks @ghengeveld! +- Globals: Repair dynamicTitle: false for user-defined tools - [#33284](https://github.com/storybookjs/storybook/pull/33284), thanks @ia319! +- Logger: Honor --loglevel for npmlog output - [#33776](https://github.com/storybookjs/storybook/pull/33776), thanks @LouisLau-art! + ## 10.2.6 - Addon-Vitest: Skip postinstall setup when configured - [#33712](https://github.com/storybookjs/storybook/pull/33712), thanks @valentinpalkovic! diff --git a/code/core/src/bin/core.ts b/code/core/src/bin/core.ts index 280e1888efe2..c930896899e7 100644 --- a/code/core/src/bin/core.ts +++ b/code/core/src/bin/core.ts @@ -2,7 +2,7 @@ import { getEnvConfig, optionalEnvToBoolean, parseList } from 'storybook/interna import { logTracker, logger } from 'storybook/internal/node-logger'; import { addToGlobalContext } from 'storybook/internal/telemetry'; -import { program } from 'commander'; +import { Option, program } from 'commander'; import leven from 'leven'; import picocolors from 'picocolors'; @@ -45,7 +45,11 @@ const command = (name: string) => ) .option('--debug', 'Get more logs in debug mode', false) .option('--enable-crash-reports', 'Enable sending crash reports to telemetry data') - .option('--loglevel ', 'Define log level', 'info') + .addOption( + new Option('--loglevel ', 'Define log level') + .choices(['trace', 'debug', 'info', 'warn', 'error', 'silent']) + .default('info') + ) .option( '--logfile [path]', 'Write all debug logs to the specified file at the end of the run. Defaults to debug-storybook.log when [path] is not provided' @@ -53,9 +57,8 @@ const command = (name: string) => .hook('preAction', async (self) => { try { const options = self.opts(); - if (options.loglevel) { - logger.setLogLevel(options.loglevel); - } + const loglevel = options.debug ? 'debug' : options.loglevel; + logger.setLogLevel(loglevel); if (options.logfile) { logTracker.enableLogWriting(); diff --git a/code/core/src/components/components/Select/Select.stories.tsx b/code/core/src/components/components/Select/Select.stories.tsx index 4861d26ae5d1..a6abd2ce3db1 100644 --- a/code/core/src/components/components/Select/Select.stories.tsx +++ b/code/core/src/components/components/Select/Select.stories.tsx @@ -1479,3 +1479,61 @@ export const ResetWithUndefinedOption = meta.story({ }); }, }); + +export const ShowSelectedOptionTitleTrue = meta.story({ + name: 'Show Selected Option Title (prop=true)', + args: { + showSelectedOptionTitle: true, + defaultOptions: 'frog', + }, + play: async ({ canvas, step }) => { + await step('Verify selected option title is shown', async () => { + const selectButton = await canvas.findByRole('button'); + expect(selectButton).toHaveTextContent('Frog'); + }); + }, +}); + +export const ShowSelectedOptionTitleFalse = meta.story({ + name: 'Show Selected Option Title (prop=false)', + args: { + showSelectedOptionTitle: false, + defaultOptions: 'frog', + }, + play: async ({ canvas, step }) => { + await step('Verify default title is shown instead of selected option', async () => { + const selectButton = await canvas.findByRole('button'); + expect(selectButton).toHaveTextContent('Animal'); + }); + }, +}); + +export const ShowSelectedOptionTitleFalseMulti = meta.story({ + name: 'Show Selected Option Title (prop=false, multi)', + args: { + showSelectedOptionTitle: false, + multiSelect: true, + defaultOptions: ['frog', 'tadpole'], + }, + play: async ({ canvas, step }) => { + await step('Verify default title is shown for multi-select', async () => { + const selectButton = await canvas.findByRole('button'); + expect(selectButton).toHaveTextContent('Animal'); + }); + }, +}); + +export const ShowSelectedOptionTitleTrueMulti = meta.story({ + name: 'Show Selected Option Title (prop=true, multi)', + args: { + showSelectedOptionTitle: true, + multiSelect: true, + defaultOptions: ['frog'], + }, + play: async ({ canvas, step }) => { + await step('Verify option count is shown for multi-select', async () => { + const selectButton = await canvas.findByRole('button'); + expect(selectButton).toHaveTextContent('1'); + }); + }, +}); diff --git a/code/core/src/components/components/Select/Select.tsx b/code/core/src/components/components/Select/Select.tsx index 0844cb7ed982..e50c192aedba 100644 --- a/code/core/src/components/components/Select/Select.tsx +++ b/code/core/src/components/components/Select/Select.tsx @@ -78,6 +78,13 @@ export interface SelectProps extends Omit< onSelect?: (option: Value) => void; onDeselect?: (option: Value) => void; onChange?: (selected: Value[]) => void; + /** + * Legacy option for ToolbarMenuSelect. Do not use in new code. Controls whether to show the + * selected option's title. + * + * @default true + */ + showSelectedOptionTitle?: boolean; } function valueToId(parentId: string, { value }: InternalOption | ResetOption): string { @@ -208,6 +215,7 @@ export const Select = forwardRef( onChange, tooltip, ariaLabel, + showSelectedOptionTitle = true, ...props }, ref @@ -522,7 +530,7 @@ export const Select = forwardRef( {!multiSelect && ( <> {icon} - {selectedOptions[0]?.title ?? children} + {(showSelectedOptionTitle && selectedOptions[0]?.title) || children} )} diff --git a/code/core/src/manager/components/preview/Viewport.tsx b/code/core/src/manager/components/preview/Viewport.tsx index 9c0fa7f46975..78d0e5910878 100644 --- a/code/core/src/manager/components/preview/Viewport.tsx +++ b/code/core/src/manager/components/preview/Viewport.tsx @@ -359,7 +359,7 @@ export const Viewport = ({ style={{ height: `${(1 / scale) * 100}%`, width: `${(1 / scale) * 100}%`, - transform: `scale(${scale})`, + transform: scale !== 1 ? `scale(${scale})` : 'none', transformOrigin: 'top left', }} > diff --git a/code/core/src/node-logger/index.test.ts b/code/core/src/node-logger/index.test.ts index 397a13d4c794..047b3a77838f 100644 --- a/code/core/src/node-logger/index.test.ts +++ b/code/core/src/node-logger/index.test.ts @@ -48,6 +48,23 @@ describe('node-logger', () => { logger.warn(message); expect(loggerMock.warn).toHaveBeenCalledWith(message); }); + + it('should sync --loglevel with npmlog', () => { + logger.setLogLevel('debug'); + expect(npmlog.level).toBe('verbose'); + expect(loggerMock.setLogLevel).toHaveBeenCalledWith('debug'); + + logger.setLogLevel('trace'); + expect(npmlog.level).toBe('silly'); + expect(loggerMock.setLogLevel).toHaveBeenCalledWith('trace'); + }); + + it('should keep setLevel and setLogLevel consistent', () => { + logger.setLevel('warn'); + expect(npmlog.level).toBe('warn'); + expect(loggerMock.setLogLevel).toHaveBeenCalledWith('warn'); + }); + it('should have an error method', () => { const message = 'error message'; logger.error(message); diff --git a/code/core/src/node-logger/index.ts b/code/core/src/node-logger/index.ts index ae2de0410ed1..e4db4c15bbf3 100644 --- a/code/core/src/node-logger/index.ts +++ b/code/core/src/node-logger/index.ts @@ -17,6 +17,22 @@ export type { LogLevel } from './logger/logger'; // there are issues with the build: https://github.com/storybookjs/storybook/issues/14621 npmLog.stream = process.stdout; +const toNpmLogLevel = (level: newLogger.LogLevel): string => { + switch (level) { + case 'trace': + return 'silly'; + case 'debug': + return 'verbose'; + default: + return level; + } +}; + +const setLoggerLevel = (level: newLogger.LogLevel = 'info'): void => { + npmLog.level = toNpmLogLevel(level); + newLogger.setLogLevel(level); +}; + function hex(hexColor: string) { // Ensure the hex color is 6 characters long and starts with '#' if (!/^#?[0-9A-Fa-f]{6}$/.test(hexColor)) { @@ -57,10 +73,8 @@ export const logger = { warn: (message: string): void => newLogger.warn(message), trace: ({ message, time }: { message: string; time: [number, number] }): void => newLogger.debug(`${message} (${colors.purple(prettyTime(time))})`), - setLevel: (level: newLogger.LogLevel = 'info'): void => { - npmLog.level = level; - newLogger.setLogLevel(level); - }, + setLevel: setLoggerLevel, + setLogLevel: setLoggerLevel, error: (message: unknown): void => { let msg: string; if (message instanceof Error && message.stack) { diff --git a/code/core/src/toolbar/components/ToolbarMenuSelect.tsx b/code/core/src/toolbar/components/ToolbarMenuSelect.tsx index 757712a90120..52aee50efbe4 100644 --- a/code/core/src/toolbar/components/ToolbarMenuSelect.tsx +++ b/code/core/src/toolbar/components/ToolbarMenuSelect.tsx @@ -29,7 +29,14 @@ export const ToolbarMenuSelect: FC = ({ id, name, description, - toolbar: { icon: _icon, items, title: _title, preventDynamicIcon, dynamicTitle, shortcuts }, + toolbar: { + icon: _icon, + items, + title: _title, + preventDynamicIcon, + dynamicTitle = true, + shortcuts, + }, }) => { const api = useStorybookApi(); const [globals, updateGlobals, storyGlobals] = useGlobals(); @@ -132,6 +139,7 @@ export const ToolbarMenuSelect: FC = ({ onReset={resetItem ? () => updateGlobals({ [id]: resetItem?.value }) : undefined} onSelect={(selected) => updateGlobals({ [id]: selected })} icon={icon && } + showSelectedOptionTitle={dynamicTitle} > {title} diff --git a/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.test.ts b/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.test.ts index a621f15f6d34..1855be5d9885 100644 --- a/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.test.ts +++ b/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.test.ts @@ -297,6 +297,62 @@ describe('stories codemod', () => { `); }); + it('migrate cross-file story imports from `ImportedStories.Story.xyz` to `ImportedStories.Story.input.xyz`', async () => { + await expect( + transform(dedent` + import * as BaseStories from './Button.stories'; + import { Primary as ImportedPrimary } from './Card.stories'; + + export default { title: 'Component' }; + + export const A = { + args: BaseStories.Primary.args, + }; + + export const B = { + ...BaseStories.Secondary, + args: { + ...BaseStories.Secondary.args, + label: 'Custom', + }, + }; + + export const C = { + args: { + ...ImportedPrimary.args, + }, + }; + `) + ).resolves.toMatchInlineSnapshot(` + import preview from '#.storybook/preview'; + + import * as BaseStories from './Button.stories'; + import { Primary as ImportedPrimary } from './Card.stories'; + + const meta = preview.meta({ + title: 'Component', + }); + + export const A = meta.story({ + args: BaseStories.Primary.input.args, + }); + + export const B = meta.story({ + ...BaseStories.Secondary.input, + args: { + ...BaseStories.Secondary.input.args, + label: 'Custom', + }, + }); + + export const C = meta.story({ + args: { + ...ImportedPrimary.input.args, + }, + }); + `); + }); + it('does not migrate reused properties from disallowed list', async () => { await expect( transform(dedent` diff --git a/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.ts b/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.ts index 8e4a23c3d44a..d92681c1e353 100644 --- a/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.ts +++ b/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.ts @@ -79,7 +79,54 @@ export async function storyToCsfFactory( const sbConfigImportSpecifier = t.importDefaultSpecifier(t.identifier(sbConfigImportName)); + /** + * Collect imports from other .stories files. + * + * When we see: import * as BaseStories from './Button.stories'; import { Primary } from + * './Card.stories'; + * + * We store the local names ("BaseStories", "Primary") so we can later transform references like + * `BaseStories.Primary.args` → `BaseStories.Primary.input.args` + * + * Why? Because those imported stories will ALSO be transformed to CSF4, so their properties will + * be under `.input` instead of directly on the object. + * + * We track TWO types of imports: + * + * - Namespace imports (import * as X): X.Story.args → X.Story.input.args + * - Named imports (import { Story }): Story.args → Story.input.args + */ + const namespaceStoryImports = new Set(); // import * as X + const namedStoryImports = new Set(); // import { X } or import X + programNode.body.forEach((node) => { + if (t.isImportDeclaration(node)) { + const importPath = node.source.value; + + // Check if this import is from a .stories file + // Matches: ./Button.stories, ../components/Card.stories.tsx, etc. + const isStoryFileImport = /\.stories(\.(ts|tsx|js|jsx|mjs|mts))?$/.test(importPath); + + if (isStoryFileImport) { + // Collect all imported names from this story file + node.specifiers.forEach((specifier) => { + if (t.isImportNamespaceSpecifier(specifier)) { + // import * as BaseStories from './Button.stories' + // BaseStories.Primary is a story, so we need: BaseStories.Primary.input + namespaceStoryImports.add(specifier.local.name); + } else if (t.isImportSpecifier(specifier)) { + // import { Primary } from './Button.stories' + // Primary itself is a story, so we need: Primary.input + namedStoryImports.add(specifier.local.name); + } else if (t.isImportDefaultSpecifier(specifier)) { + // import ButtonStories from './Button.stories' + // This typically imports the meta, not stories, so we treat it like namespace + namespaceStoryImports.add(specifier.local.name); + } + }); + } + } + if (t.isImportDeclaration(node) && isValidPreviewPath(node.source.value)) { const defaultImportSpecifier = node.specifiers.find((specifier) => t.isImportDefaultSpecifier(specifier) @@ -97,6 +144,9 @@ export async function storyToCsfFactory( const hasMeta = !!csf._meta; + // Combined set for quick lookup + const storyFileImports = new Set([...namespaceStoryImports, ...namedStoryImports]); + // @TODO: Support unconventional formats: // `export function Story() { };` and `export { Story }; // These are not part of csf._storyExports but rather csf._storyStatements and are tricky to support. @@ -180,7 +230,13 @@ export async function storyToCsfFactory( // For each story, replace any reference of story reuse e.g. // Story.args -> Story.input.args // meta.args -> meta.input.args + // BaseStories.Primary.args -> BaseStories.Primary.input.args (cross-file) traverse(csf._ast, { + /** + * Handle SAME-FILE story references. + * + * Examples: Primary.args → Primary.input.args meta.args → meta.input.args + */ Identifier(nodePath) { const identifierName = nodePath.node.name; const binding = nodePath.scope.getBinding(identifierName); @@ -227,8 +283,8 @@ export async function storyToCsfFactory( t.memberExpression(t.identifier(identifierName), t.identifier('input')) ); } catch (err: any) { - // This is a tough one to support, we just skip for now. - // Relates to `Stories.Story.args` where Stories is coming from another file. We can't know whether it should be transformed or not. + // This error occurs for cross-file references like `Stories.Story.args` + // which are handled by the MemberExpression visitor below. if (err.message.includes(`instead got "MemberExpression"`)) { return; } else { @@ -237,6 +293,136 @@ export async function storyToCsfFactory( } } }, + + /** + * Handle CROSS-FILE story references. + * + * When we import stories from another file: import * as BaseStories from './Button.stories'; + * + * And use them like: BaseStories.Primary.args + * + * We need to transform to: BaseStories.Primary.input.args + * + * Why? Because the imported file will ALSO be transformed to CSF4, where story properties are + * accessed via `.input`. + */ + MemberExpression(nodePath) { + const node = nodePath.node; + + // We're looking for patterns like: BaseStories.Primary.args + // Which is: MemberExpression { object: MemberExpression { object: Identifier, property }, property } + // + // We want to find the inner MemberExpression (BaseStories.Primary) + // and check if its object (BaseStories) is from a story file import. + + // Check if this is a nested member expression (e.g., BaseStories.Primary.args) + // We want to transform BaseStories.Primary → BaseStories.Primary.input + // So we look for MemberExpression where object is also a MemberExpression + + const innerObject = node.object; + + // Check if the object is a MemberExpression like BaseStories.Primary + if (t.isMemberExpression(innerObject)) { + const importName = innerObject.object; // BaseStories + const storyName = innerObject.property; // Primary + const accessedProperty = node.property; // args + + // Verify: importName is an Identifier that's in our storyFileImports set + if ( + t.isIdentifier(importName) && + storyFileImports.has(importName.name) && + t.isIdentifier(storyName) + ) { + // Skip if already transformed: BaseStories.Primary.input.args + // This check prevents infinite loops when the traverser revisits modified nodes + if (t.isIdentifier(storyName, { name: 'input' })) { + return; + } + + // Only process if the accessed property is an Identifier + if (!t.isIdentifier(accessedProperty)) { + return; + } + + // Skip if the current property being accessed is 'input' + // This means we're looking at something like: BaseStories.Primary.input + // which was already transformed in a previous iteration + if (accessedProperty.name === 'input') { + return; + } + + // Skip if accessing a property in the disallow list + if (reuseDisallowList.includes(accessedProperty.name)) { + return; + } + + // Transform: BaseStories.Primary.args → BaseStories.Primary.input.args + // We do this by replacing the inner object (BaseStories.Primary) + // with (BaseStories.Primary.input) + nodePath.node.object = t.memberExpression(innerObject, t.identifier('input')); + + // Skip traversing into the newly created node to prevent infinite loops + nodePath.skip(); + } + } + + // Handle NAMED IMPORTS: import { Primary } from './Button.stories' + // Usage: Primary.args → Primary.input.args + // + // Pattern: MemberExpression { object: Identifier("Primary"), property: Identifier("args") } + // Where "Primary" is in our namedStoryImports set (NOT namespace imports) + if (t.isIdentifier(innerObject) && namedStoryImports.has(innerObject.name)) { + const accessedProperty = node.property; + + // Only process if the property is an Identifier + if (!t.isIdentifier(accessedProperty)) { + return; + } + + // Skip if this is already accessing .input + if (accessedProperty.name === 'input') { + return; + } + + // Skip if accessing a property in the disallow list + if (reuseDisallowList.includes(accessedProperty.name)) { + return; + } + + // Transform: Primary.args → Primary.input.args + nodePath.replaceWith( + t.memberExpression( + t.memberExpression(innerObject, t.identifier('input')), + accessedProperty + ) + ); + nodePath.skip(); + return; + } + + // Handle NAMESPACE IMPORTS spread: import * as BaseStories from './Button.stories' + // Usage: ...BaseStories.Secondary → ...BaseStories.Secondary.input + // + // Pattern: SpreadElement containing MemberExpression { object: Identifier("BaseStories"), property: Identifier("Secondary") } + if (t.isIdentifier(innerObject) && namespaceStoryImports.has(innerObject.name)) { + const storyName = node.property; + + // Skip if this is already .input + if (t.isIdentifier(storyName, { name: 'input' })) { + return; + } + + // Check if parent is a SpreadElement (...BaseStories.Secondary) + const parent = nodePath.parent; + if (t.isSpreadElement(parent)) { + // Transform: ...BaseStories.Secondary → ...BaseStories.Secondary.input + nodePath.replaceWith(t.memberExpression(node, t.identifier('input'))); + nodePath.skip(); + } + // Note: For non-spread namespace access like BaseStories.Primary.args, + // it's handled by the nested MemberExpression case above + } + }, }); // If no stories were transformed, bail early to avoid having a mixed CSF syntax and therefore a broken indexer. diff --git a/code/package.json b/code/package.json index 488452b3c524..5f201b54909a 100644 --- a/code/package.json +++ b/code/package.json @@ -220,5 +220,6 @@ "Dependency Upgrades" ] ] - } + }, + "deferredNextVersion": "10.2.7" } diff --git a/docs/_snippets/storybook-preview-configure-globaltypes.md b/docs/_snippets/storybook-preview-configure-globaltypes.md index 651c5a63033a..4f976b95f75a 100644 --- a/docs/_snippets/storybook-preview-configure-globaltypes.md +++ b/docs/_snippets/storybook-preview-configure-globaltypes.md @@ -9,7 +9,7 @@ const preview = { icon: 'circlehollow', // Array of plain string values or MenuItem shape (see below) items: ['light', 'dark'], - // Change title based on selected value + // Change title based on selected value (recommended for consistency with the Storybook UI) dynamicTitle: true, }, }, @@ -36,7 +36,7 @@ const preview: Preview = { icon: 'circlehollow', // Array of plain string values or MenuItem shape (see below) items: ['light', 'dark'], - // Change title based on selected value + // Change title based on selected value (recommended for consistency with the Storybook UI) dynamicTitle: true, }, }, @@ -63,7 +63,7 @@ export default definePreview({ icon: 'circlehollow', // Array of plain string values or MenuItem shape (see below) items: ['light', 'dark'], - // Change title based on selected value + // Change title based on selected value (recommended for consistency with the Storybook UI) dynamicTitle: true, }, }, @@ -90,7 +90,7 @@ export default definePreview({ icon: 'circlehollow', // Array of plain string values or MenuItem shape (see below) items: ['light', 'dark'], - // Change title based on selected value + // Change title based on selected value (recommended for consistency with the Storybook UI) dynamicTitle: true, }, }, diff --git a/docs/versions/latest.json b/docs/versions/latest.json index 1c90f745339f..51ac9dcc9d6f 100644 --- a/docs/versions/latest.json +++ b/docs/versions/latest.json @@ -1 +1 @@ -{"version":"10.2.6","info":{"plain":"- Addon-Vitest: Skip postinstall setup when configured - [#33712](https://github.com/storybookjs/storybook/pull/33712), thanks @valentinpalkovic!\n- Addon-Vitest: Support vite/vitest config with deferred export - [#33755](https://github.com/storybookjs/storybook/pull/33755), thanks @valentinpalkovic!\n- CLI: Support addon-vitest setup when --skip-install is passed - [#33718](https://github.com/storybookjs/storybook/pull/33718), thanks @valentinpalkovic!\n- Manager: Update logic to use base path instead of full pathname - [#33686](https://github.com/storybookjs/storybook/pull/33686), thanks @JSMike!"}} \ No newline at end of file +{"version":"10.2.7","info":{"plain":"- CSF: Fix cross-file story imports in csf-factories codemod - [#33723](https://github.com/storybookjs/storybook/pull/33723), thanks @yatishgoel!\n- Core: Fix rendering of View Transitions in Firefox - [#33651](https://github.com/storybookjs/storybook/pull/33651), thanks @ghengeveld!\n- Globals: Repair dynamicTitle: false for user-defined tools - [#33284](https://github.com/storybookjs/storybook/pull/33284), thanks @ia319!\n- Logger: Honor --loglevel for npmlog output - [#33776](https://github.com/storybookjs/storybook/pull/33776), thanks @LouisLau-art!"}} \ No newline at end of file