diff --git a/code/addons/a11y/src/index.ts b/code/addons/a11y/src/index.ts index 583f0841fb86..48ffbe5e2c06 100644 --- a/code/addons/a11y/src/index.ts +++ b/code/addons/a11y/src/index.ts @@ -1,9 +1,10 @@ -import { definePreview } from 'storybook/preview-api'; +import { definePreviewAddon } from 'storybook/internal/csf'; import * as addonAnnotations from './preview'; +import type { A11yTypes } from './types'; export { PARAM_KEY } from './constants'; export * from './params'; -export type { A11yParameters } from './types'; +export type { A11yTypes } from './types'; -export default () => definePreview(addonAnnotations); +export default () => definePreviewAddon(addonAnnotations); diff --git a/code/addons/a11y/src/types.ts b/code/addons/a11y/src/types.ts index d1a818bd2ad4..de2472006a03 100644 --- a/code/addons/a11y/src/types.ts +++ b/code/addons/a11y/src/types.ts @@ -19,7 +19,7 @@ export interface A11yGlobals { * * @see https://storybook.js.org/docs/writing-tests/accessibility-testing */ - a11y: { + a11y?: { /** * Prevent the addon from executing automated accessibility checks upon visiting a story. You * can still trigger the checks from the addon panel. @@ -51,3 +51,8 @@ export type EnhancedResults = Omit definePreview(addonAnnotations); +export default () => definePreviewAddon(addonAnnotations); diff --git a/code/addons/docs/src/types.ts b/code/addons/docs/src/types.ts index 7ce603d7cf12..ac6ab06ddc98 100644 --- a/code/addons/docs/src/types.ts +++ b/code/addons/docs/src/types.ts @@ -164,7 +164,7 @@ export interface DocsParameters { * * @see https://storybook.js.org/docs/api/doc-blocks/doc-block-canvas */ - canvas?: CanvasBlockParameters; + canvas?: Partial; /** * Controls block configuration @@ -195,14 +195,14 @@ export interface DocsParameters { * * @see https://storybook.js.org/docs/api/doc-blocks/doc-block-source */ - source?: SourceBlockParameters; + source?: Partial; /** * Story configuration * * @see https://storybook.js.org/docs/api/doc-blocks/doc-block-story */ - story?: StoryBlockParameters; + story?: Partial; /** * The subtitle displayed when shown in docs page @@ -219,3 +219,7 @@ export interface DocsParameters { title?: string; }; } + +export interface DocsTypes { + parameters: DocsParameters; +} diff --git a/code/addons/links/src/index.ts b/code/addons/links/src/index.ts index ec01a5177326..869923579cdb 100644 --- a/code/addons/links/src/index.ts +++ b/code/addons/links/src/index.ts @@ -1,7 +1,7 @@ -import { definePreview } from 'storybook/preview-api'; +import { definePreviewAddon } from 'storybook/internal/csf'; import * as addonAnnotations from './preview'; export { linkTo, hrefTo, withLinks, navigate } from './utils'; -export default () => definePreview(addonAnnotations); +export default () => definePreviewAddon(addonAnnotations); diff --git a/code/addons/pseudo-states/src/index.ts b/code/addons/pseudo-states/src/index.ts index f02987a12309..40fcb0b5511a 100644 --- a/code/addons/pseudo-states/src/index.ts +++ b/code/addons/pseudo-states/src/index.ts @@ -1,7 +1,7 @@ -import { definePreview } from 'storybook/preview-api'; +import { definePreviewAddon } from 'storybook/internal/csf'; import * as addonAnnotations from './preview'; export { PARAM_KEY } from './constants'; -export default () => definePreview(addonAnnotations); +export default () => definePreviewAddon(addonAnnotations); diff --git a/code/addons/themes/src/index.ts b/code/addons/themes/src/index.ts index 4a02b63fdb12..428b8e12eb58 100644 --- a/code/addons/themes/src/index.ts +++ b/code/addons/themes/src/index.ts @@ -1,9 +1,10 @@ -import { definePreview } from 'storybook/preview-api'; +import { definePreviewAddon } from 'storybook/internal/csf'; import * as addonAnnotations from './preview'; +import type { ThemesTypes } from './types'; -export type { ThemesGlobals, ThemesParameters } from './types'; +export type { ThemesTypes } from './types'; -export default () => definePreview(addonAnnotations); +export default () => definePreviewAddon(addonAnnotations); export * from './decorators'; diff --git a/code/addons/themes/src/theme-switcher.tsx b/code/addons/themes/src/theme-switcher.tsx index ee3f63597b20..10eca3b4057d 100644 --- a/code/addons/themes/src/theme-switcher.tsx +++ b/code/addons/themes/src/theme-switcher.tsx @@ -17,7 +17,7 @@ import { } from './constants'; import type { ThemesParameters as Parameters, ThemeAddonState } from './types'; -type ThemesParameters = Parameters['themes']; +type ThemesParameters = NonNullable; const IconButtonLabel = styled.div(({ theme }) => ({ fontSize: theme.typography.size.s2 - 1, diff --git a/code/addons/themes/src/types.ts b/code/addons/themes/src/types.ts index 3a825e37983e..34b5c7e60979 100644 --- a/code/addons/themes/src/types.ts +++ b/code/addons/themes/src/types.ts @@ -9,7 +9,7 @@ export interface ThemesParameters { * * @see https://github.com/storybookjs/storybook/blob/next/code/addons/themes/README.md */ - themes: { + themes?: { /** Remove the addon panel and disable the addon's behavior */ disable?: boolean; /** Which theme to override for the story */ @@ -21,3 +21,8 @@ export interface ThemesGlobals { /** Which theme to override for the story */ theme?: string; } + +export interface ThemesTypes { + parameters: ThemesParameters; + globals: ThemesGlobals; +} diff --git a/code/addons/vitest/src/index.ts b/code/addons/vitest/src/index.ts index bfd4b00bc8be..ed9c6eedc4b4 100644 --- a/code/addons/vitest/src/index.ts +++ b/code/addons/vitest/src/index.ts @@ -1,5 +1,3 @@ -import { definePreview } from 'storybook/preview-api'; +import { definePreviewAddon } from 'storybook/internal/csf'; -export default () => definePreview({}); - -export type { TestParameters } from './types'; +export default () => definePreviewAddon({}); diff --git a/code/addons/vitest/src/types.ts b/code/addons/vitest/src/types.ts index 77ba1c26653c..b6eca96f92de 100644 --- a/code/addons/vitest/src/types.ts +++ b/code/addons/vitest/src/types.ts @@ -2,21 +2,6 @@ import type { experimental_UniversalStore } from 'storybook/internal/core-server import type { PreviewAnnotation, StoryId } from 'storybook/internal/types'; import type { API_HashEntry } from 'storybook/internal/types'; -export interface TestParameters { - /** - * Test addon configuration - * - * @see https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon - */ - test: { - /** Ignore unhandled errors during test execution */ - dangerouslyIgnoreUnhandledErrors?: boolean; - - /** Whether to throw exceptions coming from the play function */ - throwPlayFunctionExceptions?: boolean; - }; -} - export interface VitestError extends Error { VITEST_TEST_PATH?: string; VITEST_TEST_NAME?: string; diff --git a/code/core/src/actions/index.ts b/code/core/src/actions/index.ts index c9d2c4e55a37..d2d3261dc960 100644 --- a/code/core/src/actions/index.ts +++ b/code/core/src/actions/index.ts @@ -1,5 +1,3 @@ export * from './constants'; export * from './models'; export * from './runtime'; - -export type { ActionsParameters } from './types'; diff --git a/code/core/src/actions/preview.ts b/code/core/src/actions/preview.ts index 620d72b24ed9..8b841c1f0826 100644 --- a/code/core/src/actions/preview.ts +++ b/code/core/src/actions/preview.ts @@ -1,10 +1,13 @@ -import { definePreview } from 'storybook/preview-api'; +import { definePreviewAddon } from 'storybook/internal/csf'; import * as addArgs from './addArgs'; import * as loaders from './loaders'; +import type { ActionsTypes } from './types'; + +export { ActionsTypes }; export default () => - definePreview({ + definePreviewAddon({ ...addArgs, ...loaders, }); diff --git a/code/core/src/actions/types.ts b/code/core/src/actions/types.ts index 47b3bb9ddd84..b47e7219bfe3 100644 --- a/code/core/src/actions/types.ts +++ b/code/core/src/actions/types.ts @@ -4,7 +4,7 @@ export interface ActionsParameters { * * @see https://storybook.js.org/docs/essentials/actions#parameters */ - actions: { + actions?: { /** * Create actions for each arg that matches the regex. (**NOT recommended, see below**) * @@ -36,3 +36,7 @@ export interface ActionsParameters { handles?: string[]; }; } + +export interface ActionsTypes { + parameters: ActionsParameters; +} diff --git a/code/core/src/backgrounds/decorator.ts b/code/core/src/backgrounds/decorator.ts index e7bdbb1d167d..bb0d35de9cfc 100644 --- a/code/core/src/backgrounds/decorator.ts +++ b/code/core/src/backgrounds/decorator.ts @@ -24,7 +24,7 @@ export const withBackgroundAndGrid: DecoratorFunction = (StoryFn, context) => { options = DEFAULT_BACKGROUNDS, disable, grid = defaultGrid, - } = (parameters[PARAM_KEY] || {}) as BackgroundsParameters['backgrounds']; + } = (parameters[PARAM_KEY] || {}) as NonNullable; const data = globals[PARAM_KEY] || {}; const backgroundName: string | undefined = typeof data === 'string' ? data : data?.value; diff --git a/code/core/src/backgrounds/index.ts b/code/core/src/backgrounds/index.ts index 49fd7f86cdd5..cb0ff5c3b541 100644 --- a/code/core/src/backgrounds/index.ts +++ b/code/core/src/backgrounds/index.ts @@ -1,5 +1 @@ -import addonAnnotations from './preview'; - -export default addonAnnotations; - -export type { BackgroundsParameters, BackgroundsGlobals } from './types'; +export {}; diff --git a/code/core/src/backgrounds/preview.ts b/code/core/src/backgrounds/preview.ts index ffa9db6924d1..f7f094531073 100644 --- a/code/core/src/backgrounds/preview.ts +++ b/code/core/src/backgrounds/preview.ts @@ -1,8 +1,8 @@ -import { definePreview } from 'storybook/preview-api'; +import { definePreviewAddon } from 'storybook/internal/csf'; import { PARAM_KEY } from './constants'; import { withBackgroundAndGrid } from './decorator'; -import type { BackgroundsParameters, GlobalState } from './types'; +import type { BackgroundTypes, BackgroundsParameters, GlobalState } from './types'; const decorators = globalThis.FEATURES?.backgrounds ? [withBackgroundAndGrid] : []; @@ -21,8 +21,10 @@ const initialGlobals: Record = { [PARAM_KEY]: { value: undefined, grid: false }, }; +export type { BackgroundTypes }; + export default () => - definePreview({ + definePreviewAddon({ decorators, parameters, initialGlobals, diff --git a/code/core/src/backgrounds/types.ts b/code/core/src/backgrounds/types.ts index e82bd400204e..713a1f1d6e52 100644 --- a/code/core/src/backgrounds/types.ts +++ b/code/core/src/backgrounds/types.ts @@ -15,11 +15,19 @@ export interface GridConfig { offsetY?: number; } -export type GlobalState = { value: string | undefined; grid: boolean }; +export type GlobalState = { value: string | undefined; grid?: boolean }; export type GlobalStateUpdate = Partial; export interface BackgroundsParameters { - [PARAM_KEY]: { + /** + * Backgrounds configuration + * + * @see https://storybook.js.org/docs/essentials/backgrounds#parameters + */ + backgrounds?: { + /** Default background color */ + default?: string; + /** Remove the addon panel and disable the addon's behavior */ disable?: boolean; @@ -37,5 +45,10 @@ export interface BackgroundsGlobals { * * @see https://storybook.js.org/docs/essentials/backgrounds#globals */ - [PARAM_KEY]: GlobalState | GlobalState['value']; + [PARAM_KEY]?: GlobalState | GlobalState['value']; +} + +export interface BackgroundTypes { + parameters: BackgroundsParameters; + globals: BackgroundsGlobals; } diff --git a/code/core/src/common/utils/get-addon-annotations.ts b/code/core/src/common/utils/get-addon-annotations.ts index 1798fe231c31..f82a13bdd437 100644 --- a/code/core/src/common/utils/get-addon-annotations.ts +++ b/code/core/src/common/utils/get-addon-annotations.ts @@ -23,26 +23,24 @@ export function getAnnotationsName(addonName: string): string { return cleanedUpName; } -export async function getAddonAnnotations(addon: string) { +export async function getAddonAnnotations(addon: string, configDir: string) { + const data = { + // core addons will have a function as default export in index entrypoint + importPath: addon, + importName: getAnnotationsName(addon), + isCoreAddon: isCorePackage(addon), + }; + + if (!data.isCoreAddon) { + // for backwards compatibility, if it's not a core addon we use /preview entrypoint + data.importPath = `${addon}/preview`; + } + + // If the preview endpoint doesn't exist, we don't need to add the addon to definePreview try { - const data = { - // core addons will have a function as default export in index entrypoint - importPath: addon, - importName: getAnnotationsName(addon), - isCoreAddon: isCorePackage(addon), - }; - - if (addon === '@storybook/addon-essentials') { - return data; - } else if (!data.isCoreAddon) { - // for backwards compatibility, if it's not a core addon we use /preview entrypoint - data.importPath = `${addon}/preview`; - } - - require.resolve(path.join(addon, 'preview')); - - return data; - } catch (err) {} - - return null; + require.resolve(path.join(addon, 'preview'), { paths: [configDir] }); + } catch (err) { + return null; + } + return data; } diff --git a/code/core/src/common/utils/sync-main-preview-addons.test.ts b/code/core/src/common/utils/sync-main-preview-addons.test.ts index 35891d10778f..505d9d5cc794 100644 --- a/code/core/src/common/utils/sync-main-preview-addons.test.ts +++ b/code/core/src/common/utils/sync-main-preview-addons.test.ts @@ -22,6 +22,8 @@ describe('getSyncedStorybookAddons', () => { addons: ['custom-addon', '@storybook/addon-a11y'], }; + const configDir = '/user/storybook/.storybook'; + it('should sync addons between main and preview', async () => { const preview = loadConfig(` import * as myAddonAnnotations from "custom-addon/preview"; @@ -36,7 +38,7 @@ describe('getSyncedStorybookAddons', () => { return { importName: 'addonA11yAnnotations', importPath: '@storybook/addon-a11y/preview' }; }); - const result = await getSyncedStorybookAddons(mainConfig, preview); + const result = await getSyncedStorybookAddons(mainConfig, preview, configDir); expect(printConfig(result).code).toMatchInlineSnapshot(` import * as addonA11yAnnotations from "@storybook/addon-a11y/preview"; import * as myAddonAnnotations from "custom-addon/preview"; @@ -66,7 +68,7 @@ describe('getSyncedStorybookAddons', () => { }; }); - const result = await getSyncedStorybookAddons(mainConfig, preview); + const result = await getSyncedStorybookAddons(mainConfig, preview, configDir); expect(printConfig(result).code).toMatchInlineSnapshot(` import addonA11yAnnotations from "@storybook/addon-a11y"; import * as myAddonAnnotations from "custom-addon/preview"; @@ -92,7 +94,7 @@ describe('getSyncedStorybookAddons', () => { return { importName: 'addonA11yAnnotations', importPath: '@storybook/addon-a11y/preview' }; }); - const result = await getSyncedStorybookAddons(mainConfig, preview); + const result = await getSyncedStorybookAddons(mainConfig, preview, configDir); expect(printConfig(result).code).toMatchInlineSnapshot(` import * as addonA11yAnnotations from "@storybook/addon-a11y/preview"; import { definePreview } from "@storybook/react/preview"; @@ -122,7 +124,7 @@ describe('getSyncedStorybookAddons', () => { return { importName: 'addonA11yAnnotations', importPath: '@storybook/addon-a11y/preview' }; }); - const result = await getSyncedStorybookAddons(mainConfig, preview); + const result = await getSyncedStorybookAddons(mainConfig, preview, configDir); const transformedCode = normalizeLineBreaks(printConfig(result).code); expect(transformedCode).toMatch(originalCode); @@ -144,7 +146,7 @@ describe('getSyncedStorybookAddons', () => { return { importName: 'addonA11yAnnotations', importPath: '@storybook/addon-a11y/preview' }; }); - const result = await getSyncedStorybookAddons(mainConfig, preview); + const result = await getSyncedStorybookAddons(mainConfig, preview, configDir); const transformedCode = normalizeLineBreaks(printConfig(result).code); expect(transformedCode).toMatch(originalCode); diff --git a/code/core/src/common/utils/sync-main-preview-addons.ts b/code/core/src/common/utils/sync-main-preview-addons.ts index 4f6ee1dd478b..86891fbbd8ef 100644 --- a/code/core/src/common/utils/sync-main-preview-addons.ts +++ b/code/core/src/common/utils/sync-main-preview-addons.ts @@ -14,16 +14,21 @@ import { getAddonNames } from './get-addon-names'; const logger = console; -export async function syncStorybookAddons(mainConfig: StorybookConfig, previewConfigPath: string) { +export async function syncStorybookAddons( + mainConfig: StorybookConfig, + previewConfigPath: string, + configDir: string +) { const previewConfig = await readConfig(previewConfigPath!); - const modifiedConfig = await getSyncedStorybookAddons(mainConfig, previewConfig); + const modifiedConfig = await getSyncedStorybookAddons(mainConfig, previewConfig, configDir); await writeConfig(modifiedConfig); } export async function getSyncedStorybookAddons( mainConfig: StorybookConfig, - previewConfig: ConfigFile + previewConfig: ConfigFile, + configDir: string ): Promise { const isCsfFactory = isCsfFactoryPreview(previewConfig); @@ -43,7 +48,7 @@ export async function getSyncedStorybookAddons( * exports map called preview, if so add to the array */ for (const addon of addons) { - const annotations = await getAddonAnnotations(addon); + const annotations = await getAddonAnnotations(addon, configDir); if (annotations) { const hasAlreadyImportedAddonAnnotations = previewConfig._ast.program.body.find( (node) => t.isImportDeclaration(node) && node.source.value === annotations.importPath diff --git a/code/core/src/component-testing/preview.ts b/code/core/src/component-testing/preview.ts index 3bf37a7b2c97..6f99880cd53c 100644 --- a/code/core/src/component-testing/preview.ts +++ b/code/core/src/component-testing/preview.ts @@ -1,8 +1,7 @@ +import { definePreviewAddon } from 'storybook/internal/csf'; import { instrument } from 'storybook/internal/instrumenter'; import type { PlayFunction, StepLabel, StoryContext } from 'storybook/internal/types'; -import { definePreview } from 'storybook/preview-api'; - const { step } = instrument( { // It seems like the label is unused, but the instrumenter has access to it @@ -16,7 +15,7 @@ const { step } = instrument( ); export default () => - definePreview({ + definePreviewAddon({ parameters: { throwPlayFunctionExceptions: false, }, diff --git a/code/core/src/controls/index.ts b/code/core/src/controls/index.ts index 4fbce9564d5f..c94f80f843a1 100644 --- a/code/core/src/controls/index.ts +++ b/code/core/src/controls/index.ts @@ -1,2 +1 @@ export * from './constants'; -export * from './types'; diff --git a/code/core/src/controls/preview.ts b/code/core/src/controls/preview.ts index 450e37646b18..fc919081569a 100644 --- a/code/core/src/controls/preview.ts +++ b/code/core/src/controls/preview.ts @@ -1,6 +1,8 @@ -import { definePreview } from 'storybook/preview-api'; +import { definePreviewAddon } from 'storybook/internal/csf'; -export default definePreview({ +import type { ControlsTypes } from './types'; + +export default definePreviewAddon({ // Controls addon doesn't need any preview-side configuration // It operates entirely through the manager UI }); diff --git a/code/core/src/controls/types.ts b/code/core/src/controls/types.ts index d12dc06ad802..7623c40ec871 100644 --- a/code/core/src/controls/types.ts +++ b/code/core/src/controls/types.ts @@ -4,7 +4,7 @@ export interface ControlsParameters { * * @see https://storybook.js.org/docs/essentials/controls#parameters-1 */ - controls: { + controls?: { /** Remove the addon panel and disable the addon's behavior */ disable?: boolean; @@ -35,3 +35,7 @@ export interface ControlsParameters { sort?: 'none' | 'alpha' | 'requiredFirst'; }; } + +export interface ControlsTypes { + parameters: ControlsParameters; +} diff --git a/code/core/src/csf-tools/CsfFile.test.ts b/code/core/src/csf-tools/CsfFile.test.ts index 9a864e6ae79e..47f661991e36 100644 --- a/code/core/src/csf-tools/CsfFile.test.ts +++ b/code/core/src/csf-tools/CsfFile.test.ts @@ -2351,6 +2351,51 @@ describe('CsfFile', () => { `); }); + it('extend story', () => { + expect( + parse( + dedent` + import { config } from '#.storybook/preview' + const meta = config.meta({ component: 'foo' }); + export default meta; + export const A = meta.story({}) + export const B = A.extend({}) + ` + ) + ).toMatchInlineSnapshot(` + meta: + component: '''foo''' + title: Default Title + stories: + - id: default-title--a + name: A + __stats: + factory: true + play: false + render: false + loaders: false + beforeEach: false + globals: false + tags: false + storyFn: false + mount: false + moduleMock: false + - id: default-title--b + name: B + __stats: + factory: true + play: false + render: false + loaders: false + beforeEach: false + globals: false + tags: false + storyFn: false + mount: false + moduleMock: false + `); + }); + it('story name', () => { expect( parse( diff --git a/code/core/src/csf-tools/CsfFile.ts b/code/core/src/csf-tools/CsfFile.ts index d2a2965b5cea..fcfabd2b3e35 100644 --- a/code/core/src/csf-tools/CsfFile.ts +++ b/code/core/src/csf-tools/CsfFile.ts @@ -507,7 +507,8 @@ export class CsfFile { t.isCallExpression(storyNode) && t.isMemberExpression(storyNode.callee) && t.isIdentifier(storyNode.callee.property) && - storyNode.callee.property.name === 'story' + (storyNode.callee.property.name === 'story' || + storyNode.callee.property.name === 'extend') ) { storyIsFactory = true; storyNode = storyNode.arguments[0]; diff --git a/code/core/src/shared/preview/core-annotations.ts b/code/core/src/csf/core-annotations.ts similarity index 53% rename from code/core/src/shared/preview/core-annotations.ts rename to code/core/src/csf/core-annotations.ts index 2b4e1f8bea0e..6737a567c6c9 100644 --- a/code/core/src/shared/preview/core-annotations.ts +++ b/code/core/src/csf/core-annotations.ts @@ -1,12 +1,22 @@ import componentTestingAnnotations from 'storybook/internal/component-testing/preview'; +import type { StorybookTypes } from 'storybook/internal/types'; -import actionAnnotations from 'storybook/actions/preview'; -import backgroundsAnnotations from 'storybook/backgrounds/preview'; -import highlightAnnotations from 'storybook/highlight/preview'; -import measureAnnotations from 'storybook/measure/preview'; -import outlineAnnotations from 'storybook/outline/preview'; -import testAnnotations from 'storybook/test/preview'; -import viewportAnnotations from 'storybook/viewport/preview'; +import actionAnnotations, { type ActionsTypes } from 'storybook/actions/preview'; +import backgroundsAnnotations, { type BackgroundTypes } from 'storybook/backgrounds/preview'; +import highlightAnnotations, { type HighlightTypes } from 'storybook/highlight/preview'; +import measureAnnotations, { type MeasureTypes } from 'storybook/measure/preview'; +import outlineAnnotations, { type OutlineTypes } from 'storybook/outline/preview'; +import testAnnotations, { type TestTypes } from 'storybook/test/preview'; +import viewportAnnotations, { type ViewportTypes } from 'storybook/viewport/preview'; + +export type CoreTypes = StorybookTypes & + ActionsTypes & + BackgroundTypes & + HighlightTypes & + MeasureTypes & + OutlineTypes & + TestTypes & + ViewportTypes; export function getCoreAnnotations() { return [ diff --git a/code/core/src/csf/csf-factories.test.ts b/code/core/src/csf/csf-factories.test.ts new file mode 100644 index 000000000000..1ddce9f0f70b --- /dev/null +++ b/code/core/src/csf/csf-factories.test.ts @@ -0,0 +1,43 @@ +import { test } from 'vitest'; + +import { definePreview, definePreviewAddon } from './csf-factories'; + +interface Addon1Types { + parameters: { foo?: { value: string } }; +} + +const addon = definePreviewAddon({}); + +interface Addon2Types { + parameters: { bar?: { value: string } }; +} + +const addon2 = definePreviewAddon({}); + +const preview = definePreview({ addons: [addon, addon2] }); + +const meta = preview.meta({}); + +test('addon parameters are inferred', () => { + const MyStory = meta.story({ + parameters: { + foo: { + value: '1', + }, + bar: { + value: '1', + }, + }, + }); + const MyStory2 = meta.story({ + // @ts-expect-error can not assign numbers to strings + parameters: { + foo: { + value: 1, + }, + bar: { + value: 1, + }, + }, + }); +}); diff --git a/code/core/src/csf/csf-factories.ts b/code/core/src/csf/csf-factories.ts index f3f9072b9ef2..630af00586e1 100644 --- a/code/core/src/csf/csf-factories.ts +++ b/code/core/src/csf/csf-factories.ts @@ -1,46 +1,217 @@ +import type { AddonTypes, PlayFunction, StoryContext } from 'storybook/internal/csf'; +import { combineTags } from 'storybook/internal/csf'; import type { Args, ComponentAnnotations, - NormalizedComponentAnnotations, + ComposedStoryFn, NormalizedProjectAnnotations, - NormalizedStoryAnnotations, ProjectAnnotations, Renderer, StoryAnnotations, } from 'storybook/internal/types'; +import { + combineParameters, + composeConfigs, + composeStory, + normalizeArrays, + normalizeProjectAnnotations, +} from '../preview-api/index'; +import { getCoreAnnotations } from './core-annotations'; + export interface Preview { readonly _tag: 'Preview'; - input: ProjectAnnotations; + input: ProjectAnnotations & { addons?: PreviewAddon[] }; composed: NormalizedProjectAnnotations; - meta(input: ComponentAnnotations): Meta; + meta>( + input: TInput + ): Meta; +} + +export type InferTypes[]> = T extends PreviewAddon[] + ? C & { csf4: true } + : never; + +export function definePreview[]>( + input: ProjectAnnotations & { addons?: Addons } +): Preview> { + let composed: NormalizedProjectAnnotations>; + const preview = { + _tag: 'Preview', + input: input, + get composed() { + if (composed) { + return composed; + } + const { addons, ...rest } = input; + composed = normalizeProjectAnnotations>( + composeConfigs([...getCoreAnnotations(), ...(addons ?? []), rest]) + ); + return composed; + }, + meta(meta) { + // @ts-expect-error hard + return defineMeta(meta, this); + }, + } as Preview>; + globalThis.globalProjectAnnotations = preview.composed; + return preview; +} + +export interface PreviewAddon + extends ProjectAnnotations {} + +export function definePreviewAddon( + preview: ProjectAnnotations +): PreviewAddon { + return preview; } export function isPreview(input: unknown): input is Preview { return input != null && typeof input === 'object' && '_tag' in input && input?._tag === 'Preview'; } -export interface Meta { +export interface Meta< + TRenderer extends Renderer, + TInput extends ComponentAnnotations = ComponentAnnotations< + TRenderer, + TRenderer['args'] + >, +> { readonly _tag: 'Meta'; - input: ComponentAnnotations; - composed: NormalizedComponentAnnotations; + input: TInput; + // composed: NormalizedComponentAnnotations; preview: Preview; - story(input: StoryAnnotations): Story; + story( + input?: () => TRenderer['storyResult'] + ): Story TRenderer['storyResult'] }>; + + story>( + input?: TInput + ): Story; } export function isMeta(input: unknown): input is Meta { return input != null && typeof input === 'object' && '_tag' in input && input?._tag === 'Meta'; } -export interface Story { +function defineMeta< + TRenderer extends Renderer, + TInput extends ComponentAnnotations = ComponentAnnotations< + TRenderer, + TRenderer['args'] + >, +>(input: TInput, preview: Preview): Meta { + return { + _tag: 'Meta', + input, + preview, + get composed(): never { + throw new Error('Not implemented'); + }, + // @ts-expect-error hard + story( + story: StoryAnnotations | (() => TRenderer['storyResult']) = {} + ) { + return defineStory(typeof story === 'function' ? { render: story } : story, this); + }, + }; +} + +export interface Story< + TRenderer extends Renderer, + TInput extends StoryAnnotations = StoryAnnotations< + TRenderer, + TRenderer['args'] + >, +> { readonly _tag: 'Story'; - input: StoryAnnotations; - composed: NormalizedStoryAnnotations; - meta: Meta; + input: TInput; + composed: Pick< + ComposedStoryFn, + 'argTypes' | 'parameters' | 'id' | 'tags' | 'globals' + > & { + args: TRenderer['args']; + name: string; + }; + meta: Meta; + __compose: () => ComposedStoryFn; + play: TInput['play']; + run: (context?: Partial>>) => Promise; + + extend>( + input: TInput + ): Story; } export function isStory(input: unknown): input is Story { return input != null && typeof input === 'object' && '_tag' in input && input?._tag === 'Story'; } + +function defineStory< + TRenderer extends Renderer, + TInput extends StoryAnnotations, +>(input: TInput, meta: Meta): Story { + let composed: ComposedStoryFn; + + const compose = () => { + if (!composed) { + composed = composeStory( + input as StoryAnnotations, + meta.input as ComponentAnnotations, + undefined, + meta.preview.composed + ); + } + return composed; + }; + return { + _tag: 'Story', + input, + meta, + __compose: compose, + get composed() { + const composed = compose(); + const { args, argTypes, parameters, id, tags, globals, storyName: name } = composed; + return { args, argTypes, parameters, id, tags, name, globals }; + }, + get play() { + return input.play ?? meta.input?.play ?? (async () => {}); + }, + get run() { + return compose().run ?? (async () => {}); + }, + extend>(input: TInput) { + return defineStory( + { + ...this.input, + ...input, + args: { ...this.input.args, ...input.args }, + argTypes: combineParameters(this.input.argTypes, input.argTypes), + afterEach: [ + ...normalizeArrays(this.input?.afterEach ?? []), + ...normalizeArrays(input.afterEach ?? []), + ], + beforeEach: [ + ...normalizeArrays(this.input?.beforeEach ?? []), + ...normalizeArrays(input.beforeEach ?? []), + ], + decorators: [ + ...normalizeArrays(this.input?.decorators ?? []), + ...normalizeArrays(input.decorators ?? []), + ], + globals: { ...this.input.globals, ...input.globals }, + loaders: [ + ...normalizeArrays(this.input?.loaders ?? []), + ...normalizeArrays(input.loaders ?? []), + ], + parameters: combineParameters(this.input.parameters, input.parameters), + tags: combineTags(...(this.input.tags ?? []), ...(input.tags ?? [])), + }, + this.meta + ); + }, + }; +} diff --git a/code/core/src/csf/index.ts b/code/core/src/csf/index.ts index 4997de1b3705..5501ed1a8e00 100644 --- a/code/core/src/csf/index.ts +++ b/code/core/src/csf/index.ts @@ -89,3 +89,4 @@ export const combineTags = (...tags: string[]): string[] => { export { includeConditionalArg } from './includeConditionalArg'; export * from './story'; export * from './csf-factories'; +export * from './core-annotations'; diff --git a/code/core/src/csf/story.ts b/code/core/src/csf/story.ts index 3e685c4f1f92..76d48150a5bc 100644 --- a/code/core/src/csf/story.ts +++ b/code/core/src/csf/story.ts @@ -1,6 +1,7 @@ import type { RemoveIndexSignature, Simplify, UnionToIntersection } from 'type-fest'; import type { SBScalarType, SBType } from './SBType'; +import type { CoreTypes } from './core-annotations'; export * from './SBType'; export type StoryId = string; @@ -170,7 +171,12 @@ export interface StrictGlobalTypes { [name: string]: StrictInputType; } -export interface Renderer { +export interface AddonTypes { + parameters?: Record; + globals?: Record; +} + +export interface Renderer extends AddonTypes { /** What is the type of the `component` annotation in this renderer? */ component: any; @@ -187,6 +193,10 @@ export interface Renderer { // This generic type will eventually be filled in with TArgs // Credits to Michael Arnaldi. T?: unknown; + + args: unknown; + + csf4: boolean; } /** @deprecated - Use `Renderer` */ @@ -328,7 +338,8 @@ export interface BaseAnnotations; runStep?: StepRunner; @@ -497,7 +509,8 @@ export interface ComponentAnnotations; /** Override the globals values for all stories in this component */ - globals?: Globals; + globals?: Globals & + (TRenderer['csf4'] extends true ? CoreTypes['globals'] & TRenderer['globals'] : unknown); } export type StoryAnnotations< @@ -515,7 +528,8 @@ export type StoryAnnotations< play?: PlayFunction; /** Override the globals values for this story */ - globals?: Globals; + globals?: Globals & + (TRenderer['csf4'] extends true ? CoreTypes['globals'] & TRenderer['globals'] : unknown); /** @deprecated */ story?: Omit, 'story'>; diff --git a/code/core/src/highlight/index.ts b/code/core/src/highlight/index.ts index 938410188420..87b61478b4fa 100644 --- a/code/core/src/highlight/index.ts +++ b/code/core/src/highlight/index.ts @@ -1,7 +1,2 @@ export { HIGHLIGHT, REMOVE_HIGHLIGHT, RESET_HIGHLIGHT, SCROLL_INTO_VIEW } from './constants'; -export type { - ClickEventDetails, - HighlightMenuItem, - HighlightOptions, - HighlightParameters, -} from './types'; +export type { ClickEventDetails, HighlightMenuItem, HighlightOptions } from './types'; diff --git a/code/core/src/highlight/preview.ts b/code/core/src/highlight/preview.ts index 97298ccb3085..3e542656790c 100644 --- a/code/core/src/highlight/preview.ts +++ b/code/core/src/highlight/preview.ts @@ -1,10 +1,15 @@ /* eslint-env browser */ -import { addons, definePreview } from 'storybook/preview-api'; +import { definePreviewAddon } from 'storybook/internal/csf'; +import { addons } from 'storybook/preview-api'; + +import type { HighlightTypes } from './types'; import { useHighlights } from './useHighlights'; if (globalThis?.FEATURES?.highlight && addons?.ready) { addons.ready().then(useHighlights); } -export default () => definePreview({}); +export type { HighlightTypes }; + +export default () => definePreviewAddon({}); diff --git a/code/core/src/highlight/types.ts b/code/core/src/highlight/types.ts index 13967cea0306..12f6a9f864f1 100644 --- a/code/core/src/highlight/types.ts +++ b/code/core/src/highlight/types.ts @@ -1,12 +1,16 @@ import type { IconName } from './icons'; +export interface HighlightTypes { + parameters: HighlightParameters; +} + export interface HighlightParameters { /** * Highlight configuration * * @see https://storybook.js.org/docs/essentials/highlight#parameters */ - highlight: { + highlight?: { /** Remove the addon panel and disable the addon's behavior */ disable?: boolean; }; diff --git a/code/core/src/measure/index.ts b/code/core/src/measure/index.ts index 1e804e286e15..cb0ff5c3b541 100644 --- a/code/core/src/measure/index.ts +++ b/code/core/src/measure/index.ts @@ -1,7 +1 @@ -import { definePreview } from 'storybook/preview-api'; - -import * as addonAnnotations from './preview'; - -export type { MeasureParameters } from './types'; - -export default () => definePreview(addonAnnotations); +export {}; diff --git a/code/core/src/measure/preview.ts b/code/core/src/measure/preview.ts index d8b5e485a39d..e08b9972b071 100644 --- a/code/core/src/measure/preview.ts +++ b/code/core/src/measure/preview.ts @@ -1,6 +1,7 @@ -import { definePreview } from 'storybook/preview-api'; +import { definePreviewAddon } from 'storybook/internal/csf'; import { PARAM_KEY } from './constants'; +import type { MeasureTypes } from './types'; import { withMeasure } from './withMeasure'; export const decorators = globalThis.FEATURES?.measure ? [withMeasure] : []; @@ -9,8 +10,10 @@ export const initialGlobals = { [PARAM_KEY]: false, }; +export type { MeasureTypes }; + export default () => - definePreview({ + definePreviewAddon({ decorators, initialGlobals, }); diff --git a/code/core/src/measure/types.ts b/code/core/src/measure/types.ts index e51cf69775b5..ee6a46062a09 100644 --- a/code/core/src/measure/types.ts +++ b/code/core/src/measure/types.ts @@ -4,8 +4,12 @@ export interface MeasureParameters { * * @see https://storybook.js.org/docs/essentials/measure-and-outline#parameters */ - measure: { + measure?: { /** Remove the addon panel and disable the addon's behavior */ disable?: boolean; }; } + +export interface MeasureTypes { + parameters: MeasureParameters; +} diff --git a/code/core/src/outline/index.ts b/code/core/src/outline/index.ts index 225a831a8d08..cb0ff5c3b541 100644 --- a/code/core/src/outline/index.ts +++ b/code/core/src/outline/index.ts @@ -1,7 +1 @@ -import { definePreview } from 'storybook/preview-api'; - -import * as addonAnnotations from './preview'; - -export type { OutlineParameters } from './types'; - -export default () => definePreview(addonAnnotations); +export {}; diff --git a/code/core/src/outline/preview.ts b/code/core/src/outline/preview.ts index fee4d770ecff..86dc934e14cd 100644 --- a/code/core/src/outline/preview.ts +++ b/code/core/src/outline/preview.ts @@ -1,6 +1,7 @@ -import { definePreview } from 'storybook/preview-api'; +import { definePreviewAddon } from 'storybook/internal/csf'; import { PARAM_KEY } from './constants'; +import type { OutlineTypes } from './types'; import { withOutline } from './withOutline'; export const decorators = globalThis.FEATURES?.outline ? [withOutline] : []; @@ -9,4 +10,6 @@ export const initialGlobals = { [PARAM_KEY]: false, }; -export default () => definePreview({ decorators, initialGlobals }); +export type { OutlineTypes }; + +export default () => definePreviewAddon({ decorators, initialGlobals }); diff --git a/code/core/src/outline/types.ts b/code/core/src/outline/types.ts index b5b3d4b3d663..959fc653ea01 100644 --- a/code/core/src/outline/types.ts +++ b/code/core/src/outline/types.ts @@ -4,8 +4,12 @@ export interface OutlineParameters { * * @see https://storybook.js.org/docs/essentials/measure-and-outline#parameters */ - outline: { + outline?: { /** Remove the addon panel and disable the addon's behavior */ disable?: boolean; }; } + +export interface OutlineTypes { + parameters: OutlineParameters; +} diff --git a/code/core/src/preview-api/index.ts b/code/core/src/preview-api/index.ts index 72dfe110670f..8e6b0ca382a7 100644 --- a/code/core/src/preview-api/index.ts +++ b/code/core/src/preview-api/index.ts @@ -27,9 +27,6 @@ export { makeDecorator } from './addons'; */ export { addons, mockChannel } from './addons'; -/** ADDON ANNOTATIONS TYPE HELPER */ -export { definePreview } from './addons'; - // TODO: Universal Stores are disabled in the preview, until we get automatic leader negotiation in place // export { UniversalStore as experimental_UniversalStore } from '../shared/universal-store'; // export { useUniversalStore as experimental_useUniversalStore } from '../shared/universal-store/use-universal-store-preview'; @@ -57,6 +54,7 @@ export { defaultDecorateStory, prepareStory, prepareMeta, + normalizeArrays, normalizeStory, filterArgTypes, sanitizeStoryContextUpdate, @@ -87,4 +85,3 @@ export { waitForAnimations, } from './preview-web'; export type { SelectionStore, View } from './preview-web'; -export { getCoreAnnotations } from '../shared/preview/core-annotations'; diff --git a/code/core/src/preview-api/modules/addons/definePreview.ts b/code/core/src/preview-api/modules/addons/definePreview.ts deleted file mode 100644 index b9e74fe15a11..000000000000 --- a/code/core/src/preview-api/modules/addons/definePreview.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { ProjectAnnotations, Renderer } from 'storybook/internal/types'; - -export function definePreview(config: ProjectAnnotations): ProjectAnnotations { - return config; -} diff --git a/code/core/src/preview-api/modules/addons/index.ts b/code/core/src/preview-api/modules/addons/index.ts index db494490d7c1..b32933b0c1c9 100644 --- a/code/core/src/preview-api/modules/addons/index.ts +++ b/code/core/src/preview-api/modules/addons/index.ts @@ -1,5 +1,4 @@ export * from './main'; -export * from './definePreview'; export * from './hooks'; export * from './make-decorator'; export * from './storybook-channel-mock'; diff --git a/code/core/src/preview-api/modules/preview-web/render/MdxDocsRender.ts b/code/core/src/preview-api/modules/preview-web/render/MdxDocsRender.ts index f87c034cd621..060c08601e7d 100644 --- a/code/core/src/preview-api/modules/preview-web/render/MdxDocsRender.ts +++ b/code/core/src/preview-api/modules/preview-web/render/MdxDocsRender.ts @@ -104,7 +104,7 @@ export class MdxDocsRender implements Render( - input: Preview['input'] -): Preview { - let composed: NormalizedProjectAnnotations; - const preview: Preview = { - _tag: 'Preview', - input, - get composed() { - if (composed) { - return composed; - } - const { addons, ...rest } = input; - composed = normalizeProjectAnnotations( - composeConfigs([...getCoreAnnotations(), ...(addons ?? []), rest]) - ); - return composed; - }, - meta(meta: ComponentAnnotations) { - return defineMeta(meta, this); - }, - }; - globalThis.globalProjectAnnotations = preview.composed; - return preview; -} - -function defineMeta( - input: ComponentAnnotations, - preview: Preview -): Meta { - return { - _tag: 'Meta', - input, - preview, - get composed(): never { - throw new Error('Not implemented'); - }, - story(story: StoryAnnotations) { - return defineStory(story, this); - }, - }; -} - -function defineStory( - input: ComponentAnnotations, - meta: Meta -): Story { - return { - _tag: 'Story', - input, - meta, - get composed(): never { - throw new Error('Not implemented'); - }, - }; -} diff --git a/code/core/src/test/preview.ts b/code/core/src/test/preview.ts index b0f90b4c2efc..b7e85a3ee435 100644 --- a/code/core/src/test/preview.ts +++ b/code/core/src/test/preview.ts @@ -1,7 +1,7 @@ import type { LoaderFunction } from 'storybook/internal/csf'; +import { definePreviewAddon } from 'storybook/internal/csf'; import { instrument } from 'storybook/internal/instrumenter'; -import { definePreview } from 'storybook/preview-api'; import { clearAllMocks, fn, @@ -122,7 +122,21 @@ const enhanceContext: LoaderFunction = async (context) => { } }; +interface TestParameters { + test?: { + /** Ignore unhandled errors during test execution */ + dangerouslyIgnoreUnhandledErrors?: boolean; + + /** Whether to throw exceptions coming from the play function */ + throwPlayFunctionExceptions?: boolean; + }; +} + +export interface TestTypes { + parameters: TestParameters; +} + export default () => - definePreview({ + definePreviewAddon({ loaders: [resetAllMocksLoader, nameSpiesAndWrapActionsInSpies, enhanceContext], }); diff --git a/code/core/src/types/modules/csf.ts b/code/core/src/types/modules/csf.ts index 9d5659aca020..7208efab6d25 100644 --- a/code/core/src/types/modules/csf.ts +++ b/code/core/src/types/modules/csf.ts @@ -1,7 +1,7 @@ import type { ViewMode as ViewModeBase } from 'storybook/internal/csf'; import type { Renderer as CSFRenderer } from 'storybook/internal/csf'; -import type { Addon_OptionsParameter } from './addons'; +import type { Addon_OptionsParameterV7 } from './addons'; // Fix https://github.com/storybookjs/storybook/issues/30540 // Can be removed once @storybook/core and storybook are merged in 9.0 @@ -77,7 +77,7 @@ export type ViewMode = OrString | undefined; type Layout = 'centered' | 'fullscreen' | 'padded' | 'none'; export interface StorybookParameters { - options?: Addon_OptionsParameter; + options?: Addon_OptionsParameterV7; /** * The layout property defines basic styles added to the preview body where the story is rendered. * @@ -86,6 +86,10 @@ export interface StorybookParameters { layout?: Layout; } +export interface StorybookTypes { + parameters: StorybookParameters; +} + export interface StorybookInternalParameters extends StorybookParameters { fileName?: string; docsOnly?: true; diff --git a/code/core/src/types/modules/story.ts b/code/core/src/types/modules/story.ts index 61a23559ef20..72b963431bc6 100644 --- a/code/core/src/types/modules/story.ts +++ b/code/core/src/types/modules/story.ts @@ -44,7 +44,6 @@ export type RenderToCanvas = ( export interface ProjectAnnotations extends BaseProjectAnnotations { - addons?: ProjectAnnotations[]; testingLibraryRender?: (...args: never[]) => { unmount: () => void }; renderToCanvas?: RenderToCanvas; } diff --git a/code/core/src/viewport/components/Tool.tsx b/code/core/src/viewport/components/Tool.tsx index 0f978954cc2b..6a97c65ddd33 100644 --- a/code/core/src/viewport/components/Tool.tsx +++ b/code/core/src/viewport/components/Tool.tsx @@ -133,7 +133,7 @@ const Pure = React.memo(function PureTool(props: PureProps) { ...Object.entries(viewportMap).map(([k, value]) => ({ id: k, title: value.name, - icon: iconsMap[value.type], + icon: iconsMap[value.type!], active: k === viewportName, onClick: () => { update({ value: k, isRotated: false }); diff --git a/code/core/src/viewport/index.ts b/code/core/src/viewport/index.ts index f6d57c404fdd..097f071c30c1 100644 --- a/code/core/src/viewport/index.ts +++ b/code/core/src/viewport/index.ts @@ -1,5 +1,4 @@ export * from './constants'; export * from './types'; export * from './defaults'; -export * from './preview'; export * from './responsiveViewport'; diff --git a/code/core/src/viewport/preview.ts b/code/core/src/viewport/preview.ts index 338373d21e4e..30ca59dadd2d 100644 --- a/code/core/src/viewport/preview.ts +++ b/code/core/src/viewport/preview.ts @@ -1,13 +1,15 @@ -import { definePreview } from 'storybook/preview-api'; +import { definePreviewAddon } from 'storybook/internal/csf'; import { PARAM_KEY } from './constants'; -import type { GlobalState } from './types'; +import type { GlobalState, ViewportTypes } from './types'; export const initialGlobals: Record = { [PARAM_KEY]: { value: undefined, isRotated: false }, }; +export type { ViewportTypes }; + export default () => - definePreview({ + definePreviewAddon({ initialGlobals, }); diff --git a/code/core/src/viewport/types.ts b/code/core/src/viewport/types.ts index 3607e3c413bf..2388b0949d9b 100644 --- a/code/core/src/viewport/types.ts +++ b/code/core/src/viewport/types.ts @@ -1,7 +1,7 @@ export interface Viewport { name: string; styles: ViewportStyles; - type: 'desktop' | 'mobile' | 'tablet' | 'other'; + type?: 'desktop' | 'mobile' | 'tablet' | 'other'; } export interface ViewportStyles { @@ -22,7 +22,7 @@ export type GlobalState = { * When true the viewport applied will be rotated 90°, e.g. it will rotate from portrait to * landscape orientation. */ - isRotated: boolean; + isRotated?: boolean; }; export type GlobalStateUpdate = Partial; @@ -33,7 +33,7 @@ export interface ViewportParameters { * * @see https://storybook.js.org/docs/essentials/viewport#parameters */ - viewport: { + viewport?: { /** * Remove the addon panel and disable the addon's behavior . If you wish to turn off this addon * for the entire Storybook, you should do so when registering addon-essentials @@ -56,5 +56,10 @@ export interface ViewportGlobals { * * @see https://storybook.js.org/docs/essentials/viewport#globals */ - viewport: GlobalState | GlobalState['value']; + viewport?: GlobalState | GlobalState['value']; +} + +export interface ViewportTypes { + parameters: ViewportParameters; + globals: ViewportGlobals; } diff --git a/code/core/src/viewport/utils.tsx b/code/core/src/viewport/utils.tsx index 983965cd27c1..e3f66838aef5 100644 --- a/code/core/src/viewport/utils.tsx +++ b/code/core/src/viewport/utils.tsx @@ -37,7 +37,7 @@ export const IconButtonLabel = styled.div(({ theme }) => ({ marginLeft: 10, })); -export const iconsMap: Record = { +export const iconsMap: Record, React.ReactNode> = { desktop: , mobile: , tablet: , diff --git a/code/frameworks/nextjs-vite/src/index.ts b/code/frameworks/nextjs-vite/src/index.ts index 09da2fa5df6b..b0d1de86853d 100644 --- a/code/frameworks/nextjs-vite/src/index.ts +++ b/code/frameworks/nextjs-vite/src/index.ts @@ -1,12 +1,14 @@ +import type { AddonTypes, InferTypes, PreviewAddon } from 'storybook/internal/csf'; import type { ProjectAnnotations } from 'storybook/internal/types'; import type { ReactPreview } from '@storybook/react'; import { __definePreview } from '@storybook/react'; -import type { ReactRenderer } from '@storybook/react'; +import type { ReactTypes } from '@storybook/react'; import type vitePluginStorybookNextJs from 'vite-plugin-storybook-nextjs'; import * as nextPreview from './preview'; +import type { NextJsTypes } from './types'; export * from '@storybook/react'; // @ts-expect-error (double exports) @@ -19,14 +21,14 @@ declare module '@storybook/nextjs-vite/vite-plugin' { export const storybookNextJsPlugin: typeof vitePluginStorybookNextJs; } -export function definePreview(preview: NextPreview['input']) { +export function definePreview[]>( + preview: { addons?: Addons } & ProjectAnnotations> +): NextPreview> { + // @ts-expect-error hard return __definePreview({ ...preview, - addons: [ - nextPreview as unknown as ProjectAnnotations, - ...(preview.addons ?? []), - ], - }) as NextPreview; + addons: [nextPreview, ...(preview.addons ?? [])], + }); } -interface NextPreview extends ReactPreview {} +interface NextPreview extends ReactPreview {} diff --git a/code/frameworks/nextjs-vite/src/types.ts b/code/frameworks/nextjs-vite/src/types.ts index 29e290ff71fe..dcd8136fe5b9 100644 --- a/code/frameworks/nextjs-vite/src/types.ts +++ b/code/frameworks/nextjs-vite/src/types.ts @@ -52,9 +52,13 @@ export interface NextJsParameters { * Next.js navigation configuration when using `next/navigation`. Please note that it can only * be used in components/pages in the app directory. */ - navigation?: NextRouter; + navigation?: Partial; /** Next.js router configuration */ - router?: NextRouter; + router?: Partial; }; } + +export interface NextJsTypes { + parameters: NextJsParameters; +} diff --git a/code/frameworks/nextjs/src/index.ts b/code/frameworks/nextjs/src/index.ts index f91791742e5b..88aecbb315e6 100644 --- a/code/frameworks/nextjs/src/index.ts +++ b/code/frameworks/nextjs/src/index.ts @@ -1,18 +1,26 @@ +import type { AddonTypes, InferTypes, PreviewAddon } from 'storybook/internal/csf'; +import type { ProjectAnnotations } from 'storybook/internal/types'; + import type { ReactPreview } from '@storybook/react'; import { __definePreview } from '@storybook/react'; +import type { ReactTypes } from '@storybook/react'; import * as nextPreview from './preview'; +import type { NextJsTypes } from './types'; export * from '@storybook/react'; export * from './types'; // @ts-expect-error (double exports) export * from './portable-stories'; -export function definePreview(preview: NextPreview['input']) { +export function definePreview[]>( + preview: { addons?: Addons } & ProjectAnnotations> +): NextPreview> { + // @ts-expect-error hard return __definePreview({ ...preview, addons: [nextPreview, ...(preview.addons ?? [])], - }) as NextPreview; + }); } -interface NextPreview extends ReactPreview {} +interface NextPreview extends ReactPreview {} diff --git a/code/frameworks/nextjs/src/types.ts b/code/frameworks/nextjs/src/types.ts index 986c1a542746..2d9d0874b855 100644 --- a/code/frameworks/nextjs/src/types.ts +++ b/code/frameworks/nextjs/src/types.ts @@ -67,9 +67,13 @@ export interface NextJsParameters { * Next.js navigation configuration when using `next/navigation`. Please note that it can only * be used in components/pages in the app directory. */ - navigation?: NextRouter; + navigation?: Partial; /** Next.js router configuration */ - router?: NextRouter; + router?: Partial; }; } + +export interface NextJsTypes { + parameters: NextJsParameters; +} diff --git a/code/lib/cli-storybook/src/add.ts b/code/lib/cli-storybook/src/add.ts index ba0e8999f79f..3b9f37fa52c2 100644 --- a/code/lib/cli-storybook/src/add.ts +++ b/code/lib/cli-storybook/src/add.ts @@ -180,7 +180,7 @@ export async function add( // TODO: remove try/catch once CSF factories is shipped, for now gracefully handle any error try { - await syncStorybookAddons(mainConfig, previewConfigPath!); + await syncStorybookAddons(mainConfig, previewConfigPath!, configDir); } catch (e) { // } diff --git a/code/lib/cli-storybook/src/codemod/csf-factories.ts b/code/lib/cli-storybook/src/codemod/csf-factories.ts index cb2913280cd4..f8d5abd4f54f 100644 --- a/code/lib/cli-storybook/src/codemod/csf-factories.ts +++ b/code/lib/cli-storybook/src/codemod/csf-factories.ts @@ -66,6 +66,7 @@ export const csfFactories: CommandFix = { promptType: 'command', async run({ dryRun, + configDir, mainConfig, mainConfigPath, previewConfigPath, @@ -137,7 +138,7 @@ export const csfFactories: CommandFix = { configToCsfFactory(fileInfo, { configType: 'preview', frameworkPackage }, { dryRun }) ); - await syncStorybookAddons(mainConfig, previewConfigPath!); + await syncStorybookAddons(mainConfig, previewConfigPath!, configDir); logger.log( printBoxedMessage( 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 0f2f0759d3b3..c7e35818d15a 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 @@ -183,7 +183,7 @@ describe('stories codemod', () => { const someData = {}; - export const A = meta.story({}); + export const A = meta.story(); export const B = meta.story({ ...A.input, @@ -280,7 +280,7 @@ describe('stories codemod', () => { title: 'Component', }); - export const A = meta.story({}); + export const A = meta.story(); export const B = meta.story({ play: async () => { await A.play(); @@ -375,9 +375,7 @@ describe('stories codemod', () => { import preview from '#.storybook/preview'; const meta = preview.meta({ title: 'Component' }); - export const CSF1Story = meta.story({ - render: () =>
Hello
, - }); + export const CSF1Story = meta.story(() =>
Hello
); `); }); }); @@ -588,7 +586,7 @@ describe('stories codemod', () => { const meta = preview.meta({}); - export const A = meta.story({}); + export const A = meta.story(); `); }); }); 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 e62058da7bdc..a20c4688023c 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 @@ -113,19 +113,16 @@ export async function storyToCsfFactory( if (t.isObjectExpression(init)) { // Wrap the object in `meta.story()` + declarator.init = t.callExpression( t.memberExpression(t.identifier(metaVariableName), t.identifier('story')), - [init] + init.properties.length === 0 ? [] : [init] ); } else if (t.isArrowFunctionExpression(init)) { // Transform CSF1 to meta.story({ render: }) - const renderProperty = t.objectProperty(t.identifier('render'), init); - - const objectExpression = t.objectExpression([renderProperty]); - declarator.init = t.callExpression( t.memberExpression(t.identifier(metaVariableName), t.identifier('story')), - [objectExpression] + [init] ); } } diff --git a/code/renderers/react/src/__test__/Button.csf4.stories.tsx b/code/renderers/react/src/__test__/Button.csf4.stories.tsx index 714949efef1e..8b19ccb0d50b 100644 --- a/code/renderers/react/src/__test__/Button.csf4.stories.tsx +++ b/code/renderers/react/src/__test__/Button.csf4.stories.tsx @@ -7,7 +7,9 @@ import { expect, fn, mocked, userEvent, within } from 'storybook/test'; import { __definePreview } from '../preview'; import { Button } from './Button'; -const preview = __definePreview({}); +const preview = __definePreview({ + addons: [], +}); const meta = preview.meta({ id: 'button-component', @@ -49,7 +51,7 @@ const getCaptionForLocale = (locale: string) => { }; export const CSF2StoryWithLocale = meta.story({ - render: (args, { globals: { locale } }) => { + render: (_, { globals: { locale } }) => { const caption = getCaptionForLocale(locale); return ( <> @@ -218,8 +220,8 @@ export const WithActionArg = meta.story({ }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const buttonEl = await canvas.getByRole('button'); - await buttonEl.click(); + const buttonEl = canvas.getByRole('button'); + buttonEl.click(); }, }); @@ -290,7 +292,7 @@ export const Modal = meta.story({ }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const openModalButton = await canvas.getByRole('button', { name: /open modal/i }); + const openModalButton = canvas.getByRole('button', { name: /open modal/i }); await userEvent.click(openModalButton); await expect(within(document.body).getByRole('dialog')).toBeInTheDocument(); }, diff --git a/code/renderers/react/src/__test__/__snapshots__/portable-stories-factory.test.tsx.snap b/code/renderers/react/src/__test__/__snapshots__/portable-stories-factory.test.tsx.snap index 3f00ff746281..9882ac099027 100644 --- a/code/renderers/react/src/__test__/__snapshots__/portable-stories-factory.test.tsx.snap +++ b/code/renderers/react/src/__test__/__snapshots__/portable-stories-factory.test.tsx.snap @@ -13,6 +13,20 @@ exports[`Renders CSF2Secondary story 1`] = ` `; +exports[`Renders CSF2StoryWithLocale story 1`] = ` + +
+

+ locale: +

+
+ +`; + exports[`Renders CSF2StoryWithParamsAndDecorator story 1`] = `
diff --git a/code/renderers/react/src/__test__/portable-stories-factory.test.tsx b/code/renderers/react/src/__test__/portable-stories-factory.test.tsx index b61e935ca953..a338bcd7b755 100644 --- a/code/renderers/react/src/__test__/portable-stories-factory.test.tsx +++ b/code/renderers/react/src/__test__/portable-stories-factory.test.tsx @@ -1,79 +1,51 @@ // @vitest-environment happy-dom import { cleanup, render, screen } from '@testing-library/react'; -import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import React from 'react'; -import type { ProjectAnnotations } from 'storybook/internal/csf'; - -import type { Meta, ReactRenderer } from '@storybook/react'; - -import { expectTypeOf } from 'expect-type'; -import { addons } from 'storybook/preview-api'; - -import { composeStories, composeStory, setProjectAnnotations } from '..'; -import type { Button } from './Button'; -import * as ButtonStories from './Button.csf4.stories'; -import * as ComponentWithErrorStories from './ComponentWithError.stories'; - -const HooksStory = composeStory( - ButtonStories.HooksStory.input, - ButtonStories.CSF3Primary.meta.input -); - -const projectAnnotations = setProjectAnnotations([]); - -// example with composeStories, returns an object with all stories composed with args/decorators -// @ts-expect-error TODO: add a way to provide custom args/argTypes -const { CSF3Primary, LoaderStory, MountInPlayFunction, MountInPlayFunctionThrow } = - // @ts-expect-error TODO: add a way to provide custom args/argTypes - composeStories(ButtonStories); -const { ThrowsError } = composeStories(ComponentWithErrorStories); - -beforeAll(async () => { - await projectAnnotations.beforeAll?.(); -}); +import { + CSF2StoryWithLocale, + CSF3Button, + CSF3ButtonWithRender, + CSF3InputFieldFilled, + CSF3Primary, + MountInPlayFunction, + MountInPlayFunctionThrow, +} from './Button.csf4.stories'; +import { CSF2Secondary, HooksStory } from './Button.csf4.stories'; afterEach(() => { cleanup(); }); -// example with composeStory, returns a single story composed with args/decorators -const Secondary = composeStory( - ButtonStories.CSF2Secondary.input, - ButtonStories.CSF3Primary.meta.input -); describe('renders', () => { it('renders primary button', () => { - render(Hello world); + render(Hello world); const buttonElement = screen.getByText(/Hello world/i); expect(buttonElement).not.toBeNull(); }); it('reuses args from composed story', () => { - render(); + render(); const buttonElement = screen.getByRole('button'); - expect(buttonElement.textContent).toEqual(Secondary.args.children); + expect(buttonElement.textContent).toEqual(CSF2Secondary.input.args.children); }); it('onclick handler is called', async () => { const onClickSpy = vi.fn(); - render(); + render(); const buttonElement = screen.getByRole('button'); buttonElement.click(); expect(onClickSpy).toHaveBeenCalled(); }); it('reuses args from composeStories', () => { - const { getByText } = render(); + const { getByText } = render(); const buttonElement = getByText(/foo/i); expect(buttonElement).not.toBeNull(); }); - it('should throw error when rendering a component with a render error', async () => { - await expect(() => ThrowsError.run()).rejects.toThrowError('Error in render'); - }); - it('should render component mounted in play function', async () => { await MountInPlayFunction.run(); @@ -84,88 +56,21 @@ describe('renders', () => { it('should throw an error in play function', async () => { await expect(() => MountInPlayFunctionThrow.run()).rejects.toThrowError('Error thrown in play'); }); - - it('should call and compose loaders data', async () => { - await LoaderStory.load(); - const { getByTestId } = render(); - expect(getByTestId('spy-data').textContent).toEqual('mockFn return value'); - expect(getByTestId('loaded-data').textContent).toEqual('loaded data'); - // spy assertions happen in the play function and should work - await LoaderStory.run!(); - }); -}); - -describe('projectAnnotations', () => { - it('renders with default projectAnnotations', () => { - setProjectAnnotations([ - { - parameters: { injected: true }, - initialGlobals: { - locale: 'en', - }, - }, - ]); - const WithEnglishText = composeStory( - ButtonStories.CSF2StoryWithLocale.input, - ButtonStories.CSF3Primary.meta.input - ); - const { getByText } = render(); - const buttonElement = getByText('Hello!'); - expect(buttonElement).not.toBeNull(); - expect(WithEnglishText.parameters?.injected).toBe(true); - }); - - it('renders with custom projectAnnotations via composeStory params', () => { - const WithPortugueseText = composeStory( - ButtonStories.CSF2StoryWithLocale.input, - ButtonStories.CSF3Primary.meta.input, - { - initialGlobals: { locale: 'pt' }, - } - ); - const { getByText } = render(); - const buttonElement = getByText('Olá!'); - expect(buttonElement).not.toBeNull(); - }); - - it('has action arg from argTypes when addon-actions annotations are added', () => { - const Story = composeStory( - ButtonStories.WithActionArgType.input, - ButtonStories.CSF3Primary.meta.input - ); - - // TODO: add a way to provide custom args/argTypes, right now it's type any - expect(Story.args.someActionArg).toHaveProperty('isAction', true); - }); }); describe('CSF3', () => { it('renders with inferred globalRender', () => { - const Primary = composeStory( - ButtonStories.CSF3Button.input, - ButtonStories.CSF3Primary.meta.input - ); - - render(Hello world); + render(Hello world); const buttonElement = screen.getByText(/Hello world/i); expect(buttonElement).not.toBeNull(); }); it('renders with custom render function', () => { - const Primary = composeStory( - ButtonStories.CSF3ButtonWithRender.input, - ButtonStories.CSF3Primary.meta.input - ); - - render(); + render(); expect(screen.getByTestId('custom-render')).not.toBeNull(); }); it('renders with play function without canvas element', async () => { - const CSF3InputFieldFilled = composeStory( - ButtonStories.CSF3InputFieldFilled.input, - ButtonStories.CSF3Primary.meta.input - ); await CSF3InputFieldFilled.run(); const input = screen.getByTestId('input') as HTMLInputElement; @@ -173,11 +78,6 @@ describe('CSF3', () => { }); it('renders with play function with canvas element', async () => { - const CSF3InputFieldFilled = composeStory( - ButtonStories.CSF3InputFieldFilled.input, - ButtonStories.CSF3Primary.meta.input - ); - let divElement; try { divElement = document.createElement('div'); @@ -202,53 +102,15 @@ describe('CSF3', () => { }); }); -// common in addons that need to communicate between manager and preview -it('should pass with decorators that need addons channel', () => { - const PrimaryWithChannels = composeStory( - ButtonStories.CSF3Primary.input, - ButtonStories.CSF3Primary.meta.input, - { - decorators: [ - (StoryFn: any) => { - addons.getChannel(); - return StoryFn(); - }, - ], - } - ); - render(Hello world); - const buttonElement = screen.getByText(/Hello world/i); - expect(buttonElement).not.toBeNull(); -}); - -describe('ComposeStories types', () => { - // this file tests Typescript types that's why there are no assertions - it('Should support typescript operators', () => { - type ComposeStoriesParam = Parameters[0]; - - expectTypeOf({ - ...ButtonStories, - default: ButtonStories.CSF3Primary.meta.input as Meta, - }).toMatchTypeOf(); - - expectTypeOf({ - ...ButtonStories, - default: ButtonStories.CSF3Primary.meta.input satisfies Meta, - }).toMatchTypeOf(); - }); -}); - -// @ts-expect-error TODO: fix the types for this -const testCases = Object.values(composeStories(ButtonStories)).map( - // @ts-expect-error TODO: fix the types for this - (Story) => [Story.storyName, Story] as [string, typeof Story] -); +const testCases = Object.entries({ + CSF2StoryWithLocale, + CSF3Button, + CSF3ButtonWithRender, + CSF3InputFieldFilled, + CSF3Primary, + MountInPlayFunction, +}).map(([name, Story]) => [name, Story] as [string, typeof Story]); it.each(testCases)('Renders %s story', async (_storyName, Story) => { - if (_storyName === 'CSF2StoryWithLocale' || _storyName === 'MountInPlayFunctionThrow') { - return; - } - - // @ts-expect-error TODO: fix the types for this await Story.run(); expect(document.body).toMatchSnapshot(); }); diff --git a/code/renderers/react/src/csf-factories.test.tsx b/code/renderers/react/src/csf-factories.test.tsx index 7a9aa5093d7b..03b0de64da0a 100644 --- a/code/renderers/react/src/csf-factories.test.tsx +++ b/code/renderers/react/src/csf-factories.test.tsx @@ -3,7 +3,7 @@ import { describe, it } from 'vitest'; import { expect, test } from 'vitest'; -import type { KeyboardEventHandler, ReactElement, ReactNode } from 'react'; +import type { ComponentType, KeyboardEventHandler, ReactElement, ReactNode } from 'react'; import React from 'react'; import type { Canvas } from 'storybook/internal/csf'; @@ -16,10 +16,12 @@ import type { Mock } from 'storybook/test'; import { __definePreview } from './preview'; import type { Decorator } from './public-types'; -type ButtonProps = { label: string; disabled: boolean }; +type ButtonProps = { label: string; disabled: boolean; onKeyDown?: () => void }; const Button: (props: ButtonProps) => ReactElement = () => <>; -const preview = __definePreview({}); +const preview = __definePreview({ + addons: [], +}); test('csf factories', () => { const config = __definePreview({ @@ -92,9 +94,10 @@ describe('Args can be provided in multiple ways', () => { args: { label: 'good' }, }); const Basic = meta.story({ - args: {}, render: () =>
Hello world
, }); + + const CSF1 = meta.story(() =>
Hello world
); }); it('❌ Required args need to be provided when the user uses a non-empty render', () => { @@ -214,6 +217,56 @@ describe('Story args can be inferred', () => { args: { decoratorArg: 0, decoratorArg2: '', label: 'good' }, }); }); + + it('Component type can be overridden', () => { + const meta = preview.meta({ + component: Button as unknown as ComponentType< + Omit & { onKeyDown?: boolean } + >, + render: ({ onKeyDown, ...args }) => { + return