diff --git a/.circleci/config.yml b/.circleci/config.yml index ae9e022cf6a7..cd966b042091 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -233,7 +233,7 @@ jobs: name: Run tests command: | cd scripts - yarn test --coverage --coverage.all=false + yarn test --coverage - store_test_results: path: scripts/junit.xml - report-workflow-on-failure @@ -241,7 +241,7 @@ jobs: unit-tests: executor: class: xlarge - name: sb_node_22_browsers + name: sb_playwright steps: - git-shallow-clone/checkout_advanced: clone_options: "--depth 1 --verbose" @@ -251,7 +251,7 @@ jobs: name: Test command: | cd code - yarn test --coverage --coverage.all=false + yarn test --coverage - store_test_results: path: code/junit.xml - persist_to_workspace: @@ -398,6 +398,26 @@ jobs: template: $(yarn get-template --cadence << pipeline.parameters.workflow >> --task test-runner) - store_test_results: path: test-results + vitest-integration: + parameters: + parallelism: + type: integer + executor: + class: large + name: sb_playwright + parallelism: << parameters.parallelism >> + steps: + - git-shallow-clone/checkout_advanced: + clone_options: "--depth 1 --verbose" + - attach_workspace: + at: . + - run: + name: Running story tests in Vitest + command: yarn task --task vitest-integration --template $(yarn get-template --cadence << pipeline.parameters.workflow >> --task vitest-integration) --no-link --start-from=never --junit + - report-workflow-on-failure: + template: $(yarn get-template --cadence << pipeline.parameters.workflow >> --task vitest-integration) + - store_test_results: + path: test-results test-runner-dev: parameters: parallelism: @@ -679,19 +699,19 @@ workflows: requires: - unit-tests - create-sandboxes: - parallelism: 13 + parallelism: 14 requires: - build - build-sandboxes: - parallelism: 13 + parallelism: 14 requires: - create-sandboxes - chromatic-sandboxes: - parallelism: 10 + parallelism: 11 requires: - build-sandboxes - e2e-production: - parallelism: 8 + parallelism: 9 requires: - build-sandboxes - e2e-dev: @@ -699,9 +719,13 @@ workflows: requires: - create-sandboxes - test-runner-production: - parallelism: 8 + parallelism: 9 requires: - build-sandboxes + - vitest-integration: + parallelism: 4 + requires: + - create-sandboxes - bench: parallelism: 5 requires: @@ -741,19 +765,19 @@ workflows: requires: - unit-tests - create-sandboxes: - parallelism: 19 + parallelism: 20 requires: - build - build-sandboxes: - parallelism: 19 + parallelism: 20 requires: - create-sandboxes - chromatic-sandboxes: - parallelism: 16 + parallelism: 17 requires: - build-sandboxes - e2e-production: - parallelism: 14 + parallelism: 15 requires: - build-sandboxes - e2e-dev: @@ -761,9 +785,13 @@ workflows: requires: - create-sandboxes - test-runner-production: - parallelism: 14 + parallelism: 15 requires: - build-sandboxes + - vitest-integration: + parallelism: 4 + requires: + - create-sandboxes - test-portable-stories: requires: - build @@ -827,6 +855,10 @@ workflows: parallelism: 33 requires: - build-sandboxes + - vitest-integration: + parallelism: 8 + requires: + - create-sandboxes - test-portable-stories: requires: - build diff --git a/.github/workflows/tests-unit.yml b/.github/workflows/tests-unit.yml index 462f30daf183..d50dc7efdf27 100644 --- a/.github/workflows/tests-unit.yml +++ b/.github/workflows/tests-unit.yml @@ -24,5 +24,9 @@ jobs: - name: install and compile run: yarn task --task compile --start-from=auto + + - name: Install Playwright Dependencies + run: cd code && yarn exec playwright install chromium --with-deps + - name: test run: yarn test diff --git a/MIGRATION.md b/MIGRATION.md index 7235c43b2319..72fd9adff5f0 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,6 +1,7 @@

Migration

- [From version 8.2.x to 8.3.x](#from-version-82x-to-83x) + - [Removed `experimental_SIDEBAR_BOTTOM` and deprecated `experimental_SIDEBAR_TOP` addon types](#removed-experimental_sidebar_bottom-and-deprecated-experimental_sidebar_top-addon-types) - [New parameters format for addon backgrounds](#new-parameters-format-for-addon-backgrounds) - [New parameters format for addon viewport](#new-parameters-format-for-addon-viewport) - [From version 8.1.x to 8.2.x](#from-version-81x-to-82x) @@ -104,17 +105,17 @@ - [Tab addons cannot manually route, Tool addons can filter their visibility via tabId](#tab-addons-cannot-manually-route-tool-addons-can-filter-their-visibility-via-tabid) - [Removed `config` preset](#removed-config-preset-1) - [From version 7.5.0 to 7.6.0](#from-version-750-to-760) - - [CommonJS with Vite is deprecated](#commonjs-with-vite-is-deprecated) - - [Using implicit actions during rendering is deprecated](#using-implicit-actions-during-rendering-is-deprecated) - - [typescript.skipBabel deprecated](#typescriptskipbabel-deprecated) - - [Primary doc block accepts of prop](#primary-doc-block-accepts-of-prop) - - [Addons no longer need a peer dependency on React](#addons-no-longer-need-a-peer-dependency-on-react) + - [CommonJS with Vite is deprecated](#commonjs-with-vite-is-deprecated) + - [Using implicit actions during rendering is deprecated](#using-implicit-actions-during-rendering-is-deprecated) + - [typescript.skipBabel deprecated](#typescriptskipbabel-deprecated) + - [Primary doc block accepts of prop](#primary-doc-block-accepts-of-prop) + - [Addons no longer need a peer dependency on React](#addons-no-longer-need-a-peer-dependency-on-react) - [From version 7.4.0 to 7.5.0](#from-version-740-to-750) - - [`storyStoreV6` and `storiesOf` is deprecated](#storystorev6-and-storiesof-is-deprecated) - - [`storyIndexers` is replaced with `experimental_indexers`](#storyindexers-is-replaced-with-experimental_indexers) + - [`storyStoreV6` and `storiesOf` is deprecated](#storystorev6-and-storiesof-is-deprecated) + - [`storyIndexers` is replaced with `experimental_indexers`](#storyindexers-is-replaced-with-experimental_indexers) - [From version 7.0.0 to 7.2.0](#from-version-700-to-720) - - [Addon API is more type-strict](#addon-api-is-more-type-strict) - - [Addon-controls hideNoControlsWarning parameter is deprecated](#addon-controls-hidenocontrolswarning-parameter-is-deprecated) + - [Addon API is more type-strict](#addon-api-is-more-type-strict) + - [Addon-controls hideNoControlsWarning parameter is deprecated](#addon-controls-hidenocontrolswarning-parameter-is-deprecated) - [From version 6.5.x to 7.0.0](#from-version-65x-to-700) - [7.0 breaking changes](#70-breaking-changes) - [Dropped support for Node 15 and below](#dropped-support-for-node-15-and-below) @@ -140,7 +141,7 @@ - [Deploying build artifacts](#deploying-build-artifacts) - [Dropped support for file URLs](#dropped-support-for-file-urls) - [Serving with nginx](#serving-with-nginx) - - [Ignore story files from node\_modules](#ignore-story-files-from-node_modules) + - [Ignore story files from node_modules](#ignore-story-files-from-node_modules) - [7.0 Core changes](#70-core-changes) - [7.0 feature flags removed](#70-feature-flags-removed) - [Story context is prepared before for supporting fine grained updates](#story-context-is-prepared-before-for-supporting-fine-grained-updates) @@ -154,7 +155,7 @@ - [Addon-interactions: Interactions debugger is now default](#addon-interactions-interactions-debugger-is-now-default) - [7.0 Vite changes](#70-vite-changes) - [Vite builder uses Vite config automatically](#vite-builder-uses-vite-config-automatically) - - [Vite cache moved to node\_modules/.cache/.vite-storybook](#vite-cache-moved-to-node_modulescachevite-storybook) + - [Vite cache moved to node_modules/.cache/.vite-storybook](#vite-cache-moved-to-node_modulescachevite-storybook) - [7.0 Webpack changes](#70-webpack-changes) - [Webpack4 support discontinued](#webpack4-support-discontinued) - [Babel mode v7 exclusively](#babel-mode-v7-exclusively) @@ -204,7 +205,7 @@ - [Dropped addon-docs manual babel configuration](#dropped-addon-docs-manual-babel-configuration) - [Dropped addon-docs manual configuration](#dropped-addon-docs-manual-configuration) - [Autoplay in docs](#autoplay-in-docs) - - [Removed STORYBOOK\_REACT\_CLASSES global](#removed-storybook_react_classes-global) + - [Removed STORYBOOK_REACT_CLASSES global](#removed-storybook_react_classes-global) - [7.0 Deprecations and default changes](#70-deprecations-and-default-changes) - [storyStoreV7 enabled by default](#storystorev7-enabled-by-default) - [`Story` type deprecated](#story-type-deprecated) @@ -419,6 +420,12 @@ ## From version 8.2.x to 8.3.x +### Removed `experimental_SIDEBAR_BOTTOM` and deprecated `experimental_SIDEBAR_TOP` addon types + +The experimental SIDEBAR_BOTTOM addon type was removed in favor of a built-in filter UI. The enum type definition will remain available until Storybook 9.0 but will be ignored. Similarly the experimental SIDEBAR_TOP addon type is deprecated and will be removed in a future version. + +These APIs allowed addons to render arbitrary content in the Storybook sidebar. Due to potential conflicts between addons and challenges regarding styling, these APIs are/will be removed. In the future, Storybook will provide declarative API hooks to allow addons to add content to the sidebar without risk of conflicts or UI inconsistencies. One such API is `experimental_updateStatus` which allow addons to set a status for stories. The SIDEBAR_BOTTOM slot is now used to allow filtering stories with a given status. + ### New parameters format for addon backgrounds The `addon-backgrounds` addon now uses a new format for parameters. The `backgrounds` parameter is now an object with a `values` key that contains the background values. @@ -448,7 +455,7 @@ Setting an override value should now be done via a `globals` property on your co export default { component: Button, globals: { - backgrounds: { value: 'twitter' }, + backgrounds: { value: "twitter" }, }, }; ``` @@ -494,7 +501,7 @@ Setting an override value should now be done via a `globals` property on your co export default { component: Button, globals: { - viewport: { value: 'phone' }, + viewport: { value: "phone" }, }, }; ``` @@ -2411,8 +2418,8 @@ export default config; #### Vite builder uses Vite config automatically -When using a [Vite-based framework](#framework-field-mandatory), Storybook will automatically use your `vite.config.(ctm)js` config file starting in 7.0. -Some settings will be overridden by Storybook so that it can function properly, and the merged settings can be modified using `viteFinal` in `.storybook/main.js` (see the [Storybook Vite configuration docs](https://storybook.js.org/docs/react/builders/vite#configuration)). +When using a [Vite-based framework](#framework-field-mandatory), Storybook will automatically use your `vite.config.(ctm)js` config file starting in 7.0. +Some settings will be overridden by Storybook so that it can function properly, and the merged settings can be modified using `viteFinal` in `.storybook/main.js` (see the [Storybook Vite configuration docs](https://storybook.js.org/docs/react/builders/vite#configuration)). If you were using `viteFinal` in 6.5 to simply merge in your project's standard Vite config, you can now remove it. For Svelte projects this means that the `svelteOptions` property in the `main.js` config should be omitted, as it will be loaded automatically via the project's `vite.config.js`. diff --git a/code/.gitignore b/code/.gitignore index 0cba03f65076..f71236a5f726 100644 --- a/code/.gitignore +++ b/code/.gitignore @@ -1 +1,2 @@ -.nx/cache \ No newline at end of file +.nx/cache +.vite-inspect \ No newline at end of file diff --git a/code/.storybook/main.ts b/code/.storybook/main.ts index 52e8a12175b4..0d0f1a7cf190 100644 --- a/code/.storybook/main.ts +++ b/code/.storybook/main.ts @@ -94,6 +94,7 @@ const config: StorybookConfig = { '@storybook/addon-interactions', '@storybook/addon-storysource', '@storybook/addon-designs', + '@storybook/experimental-addon-vitest', '@storybook/addon-a11y', '@chromatic-com/storybook', ], diff --git a/code/.storybook/preview.tsx b/code/.storybook/preview.tsx index 07d79f194746..3fbae06f064f 100644 --- a/code/.storybook/preview.tsx +++ b/code/.storybook/preview.tsx @@ -1,4 +1,5 @@ -import React, { Fragment, useEffect } from 'react'; +import * as React from 'react'; +import { Fragment, useEffect } from 'react'; import type { Channel } from 'storybook/internal/channels'; import { DocsContext as DocsContextProps, useArgs } from 'storybook/internal/preview-api'; @@ -160,7 +161,7 @@ export const loaders = [ } return { docsContext }; }, -]; +] as Loader[]; export const decorators = [ // This decorator adds the DocsContext created in the loader above diff --git a/code/.storybook/storybook.setup.ts b/code/.storybook/storybook.setup.ts new file mode 100644 index 000000000000..41573bb356b7 --- /dev/null +++ b/code/.storybook/storybook.setup.ts @@ -0,0 +1,38 @@ +import { beforeAll, vi, expect as vitestExpect } from 'vitest'; + +import { setProjectAnnotations } from '@storybook/react'; +import { userEvent as storybookEvent, expect as storybookExpect } from '@storybook/test'; + +import * as coreAnnotations from '../addons/toolbars/template/stories/preview'; +import * as componentAnnotations from '../core/template/stories/preview'; +// register global components used in many stories +import '../renderers/react/template/components'; +import * as projectAnnotations from './preview'; + +vi.spyOn(console, 'warn').mockImplementation((...args) => console.log(...args)); + +const annotations = setProjectAnnotations([ + // @ts-expect-error check type errors later + projectAnnotations, + // @ts-expect-error check type errors later + componentAnnotations, + coreAnnotations, + { + // experiment with injecting Vitest's interactivity API over our userEvent while tests run in browser mode + // https://vitest.dev/guide/browser/interactivity-api.html + loaders: async (context) => { + // eslint-disable-next-line no-underscore-dangle + if (globalThis.__vitest_browser__) { + const vitest = await import('@vitest/browser/context'); + const { userEvent: browserEvent } = vitest; + context.userEvent = browserEvent.setup(); + context.expect = vitestExpect; + } else { + context.userEvent = storybookEvent.setup(); + context.expect = storybookExpect; + } + }, + }, +]); + +beforeAll(annotations.beforeAll); diff --git a/code/.storybook/vitest.config.ts b/code/.storybook/vitest.config.ts new file mode 100644 index 000000000000..a9e26b6ddbae --- /dev/null +++ b/code/.storybook/vitest.config.ts @@ -0,0 +1,56 @@ +import { defaultExclude, defineProject, mergeConfig } from 'vitest/config'; + +import Inspect from 'vite-plugin-inspect'; + +import { vitestCommonConfig } from '../vitest.workspace'; + +const extraPlugins: any[] = []; +if (process.env.INSPECT === 'true') { + // this plugin assists in inspecting the Storybook Vitest plugin's transformation and sourcemaps + extraPlugins.push( + Inspect({ + outputDir: '../.vite-inspect', + build: true, + open: true, + include: ['**/*.stories.*'], + }) + ); +} + +export default mergeConfig( + vitestCommonConfig, + defineProject({ + plugins: [ + import('@storybook/experimental-addon-vitest/plugin').then(({ storybookTest }) => + storybookTest({ + configDir: process.cwd(), + }) + ), + ...extraPlugins, + ], + test: { + name: 'storybook-ui', + include: [ + // TODO: test all core and addon stories later + // './core/**/components/**/*.{story,stories}.?(c|m)[jt]s?(x)', + '../addons/interactions/src/**/*.{story,stories}.?(c|m)[jt]s?(x)', + ], + exclude: [ + ...defaultExclude, + '../node_modules/**', + '**/__mockdata__/**', + // expected to fail in Vitest because of fetching /iframe.html to cause ECONNREFUSED + '**/Zoom.stories.tsx', + ], + browser: { + enabled: true, + name: 'chromium', + provider: 'playwright', + headless: true, + screenshotFailures: false, + }, + setupFiles: ['./storybook.setup.ts'], + environment: 'happy-dom', + }, + }) +); diff --git a/code/addons/a11y/package.json b/code/addons/a11y/package.json index 7be002827f45..4bc6ef57db87 100644 --- a/code/addons/a11y/package.json +++ b/code/addons/a11y/package.json @@ -61,7 +61,7 @@ }, "devDependencies": { "@storybook/global": "^5.0.0", - "@storybook/icons": "^1.2.5", + "@storybook/icons": "^1.2.10", "@testing-library/react": "^14.0.0", "lodash": "^4.17.21", "react": "^18.2.0", diff --git a/code/addons/backgrounds/package.json b/code/addons/backgrounds/package.json index 0ec6afd73406..11d8ba0da681 100644 --- a/code/addons/backgrounds/package.json +++ b/code/addons/backgrounds/package.json @@ -61,7 +61,7 @@ "ts-dedent": "^2.0.0" }, "devDependencies": { - "@storybook/icons": "^1.2.5", + "@storybook/icons": "^1.2.10", "react": "^18.2.0", "react-dom": "^18.2.0", "typescript": "^5.3.2" diff --git a/code/addons/controls/package.json b/code/addons/controls/package.json index 7eb88c5d0455..dec5c9691195 100644 --- a/code/addons/controls/package.json +++ b/code/addons/controls/package.json @@ -57,7 +57,7 @@ }, "devDependencies": { "@storybook/blocks": "workspace:*", - "@storybook/icons": "^1.2.5", + "@storybook/icons": "^1.2.10", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/code/addons/docs/template/stories/docspage/error.stories.ts b/code/addons/docs/template/stories/docspage/error.stories.ts index 545448902b6c..ad24bc585f68 100644 --- a/code/addons/docs/template/stories/docspage/error.stories.ts +++ b/code/addons/docs/template/stories/docspage/error.stories.ts @@ -2,7 +2,7 @@ import { global as globalThis } from '@storybook/global'; export default { component: globalThis.Components.Button, - tags: ['autodocs', '!test'], + tags: ['autodocs', '!test', '!vitest'], args: { label: 'Click Me!' }, parameters: { chromatic: { disable: true } }, }; diff --git a/code/addons/interactions/template/stories/basics.stories.ts b/code/addons/interactions/template/stories/basics.stories.ts index e208654602ea..2e07b7c0e624 100644 --- a/code/addons/interactions/template/stories/basics.stories.ts +++ b/code/addons/interactions/template/stories/basics.stories.ts @@ -100,7 +100,7 @@ export const WithLoaders = { }, }; -export const UserEventSetup = { +const UserEventSetup = { play: async (context) => { const { args, canvasElement, step } = context; const user = userEvent.setup(); @@ -123,3 +123,5 @@ export const UserEventSetup = { }); }, }; + +export { UserEventSetup }; diff --git a/code/addons/interactions/template/stories/unhandled-errors.stories.ts b/code/addons/interactions/template/stories/unhandled-errors.stories.ts index 0f8409ad792a..b9be0df743e0 100644 --- a/code/addons/interactions/template/stories/unhandled-errors.stories.ts +++ b/code/addons/interactions/template/stories/unhandled-errors.stories.ts @@ -14,7 +14,7 @@ export default { actions: { argTypesRegex: '^on[A-Z].*' }, chromatic: { disable: true }, }, - tags: ['!test'], + tags: ['!test', '!vitest'], }; export const Default = { diff --git a/code/addons/jest/package.json b/code/addons/jest/package.json index e066cafd9e1c..37d2d53f9430 100644 --- a/code/addons/jest/package.json +++ b/code/addons/jest/package.json @@ -59,7 +59,7 @@ "upath": "^2.0.1" }, "devDependencies": { - "@storybook/icons": "^1.2.5", + "@storybook/icons": "^1.2.10", "react": "^18.2.0", "react-dom": "^18.2.0", "react-resize-detector": "^7.1.2", diff --git a/code/addons/measure/package.json b/code/addons/measure/package.json index 6307ecd96484..07da904688da 100644 --- a/code/addons/measure/package.json +++ b/code/addons/measure/package.json @@ -72,7 +72,7 @@ "tiny-invariant": "^1.3.1" }, "devDependencies": { - "@storybook/icons": "^1.2.5", + "@storybook/icons": "^1.2.10", "react": "^18.2.0", "react-dom": "^18.2.0", "typescript": "^5.3.2" diff --git a/code/addons/onboarding/package.json b/code/addons/onboarding/package.json index b02b10fb28aa..74c2d5f696d1 100644 --- a/code/addons/onboarding/package.json +++ b/code/addons/onboarding/package.json @@ -50,7 +50,7 @@ }, "devDependencies": { "@radix-ui/react-dialog": "^1.0.5", - "@storybook/icons": "^1.2.5", + "@storybook/icons": "^1.2.10", "@storybook/react": "workspace:*", "framer-motion": "^11.0.3", "react": "^18.2.0", diff --git a/code/addons/outline/package.json b/code/addons/outline/package.json index 74cad460b656..e115f1f86515 100644 --- a/code/addons/outline/package.json +++ b/code/addons/outline/package.json @@ -62,7 +62,7 @@ "ts-dedent": "^2.0.0" }, "devDependencies": { - "@storybook/icons": "^1.2.5", + "@storybook/icons": "^1.2.10", "react": "^18.2.0", "react-dom": "^18.2.0", "typescript": "^5.3.2" diff --git a/code/addons/themes/package.json b/code/addons/themes/package.json index 93a79944af64..280603885474 100644 --- a/code/addons/themes/package.json +++ b/code/addons/themes/package.json @@ -60,7 +60,7 @@ "ts-dedent": "^2.0.0" }, "devDependencies": { - "@storybook/icons": "^1.2.5", + "@storybook/icons": "^1.2.10", "typescript": "^5.3.2" }, "peerDependencies": { diff --git a/code/addons/viewport/package.json b/code/addons/viewport/package.json index d5fcb54313d1..2cd03c7c1610 100644 --- a/code/addons/viewport/package.json +++ b/code/addons/viewport/package.json @@ -56,7 +56,7 @@ }, "devDependencies": { "@storybook/global": "^5.0.0", - "@storybook/icons": "^1.2.5", + "@storybook/icons": "^1.2.10", "react": "^18.2.0", "react-dom": "^18.2.0", "typescript": "^5.3.2" diff --git a/code/addons/viewport/template/stories/parameters.stories.ts b/code/addons/viewport/template/stories/parameters.stories.ts index b715564e1753..697f675c214a 100644 --- a/code/addons/viewport/template/stories/parameters.stories.ts +++ b/code/addons/viewport/template/stories/parameters.stories.ts @@ -1,4 +1,5 @@ import { global as globalThis } from '@storybook/global'; +import { expect } from '@storybook/test'; import { MINIMAL_VIEWPORTS } from '@storybook/addon-viewport'; @@ -30,6 +31,21 @@ export const Selected = { defaultViewport: first, }, }, + play: async () => { + const viewportStyles = MINIMAL_VIEWPORTS[first].styles; + const viewportDimensions = { + width: typeof viewportStyles === 'object' && Number.parseInt(viewportStyles!.width, 10), + height: typeof viewportStyles === 'object' && Number.parseInt(viewportStyles!.height, 10), + }; + + const windowDimensions = { + width: window.innerWidth, + height: window.innerHeight, + }; + + await expect(viewportDimensions).toEqual(windowDimensions); + }, + tags: ['!test'], }; export const Orientation = { diff --git a/code/addons/vitest/README.md b/code/addons/vitest/README.md new file mode 100644 index 000000000000..db80cceae151 --- /dev/null +++ b/code/addons/vitest/README.md @@ -0,0 +1,3 @@ +# Storybook Addon Vitest (Experimental) + +Addon to integrate Vitest test results with Storybook. diff --git a/code/addons/vitest/package.json b/code/addons/vitest/package.json new file mode 100644 index 000000000000..da7f6259f1f6 --- /dev/null +++ b/code/addons/vitest/package.json @@ -0,0 +1,104 @@ +{ + "name": "@storybook/experimental-addon-vitest", + "version": "8.3.0-alpha.5", + "description": "Integrate Vitest with Storybook", + "keywords": [ + "storybook-addons", + "addon-vitest", + "vitest", + "testing" + ], + "homepage": "https://github.com/storybookjs/storybook/tree/next/code/addons/vitest", + "bugs": { + "url": "https://github.com/storybookjs/storybook/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/storybookjs/storybook.git", + "directory": "code/addons/vitest" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "license": "MIT", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs", + "node": "./dist/index.cjs" + }, + "./plugin": { + "types": "./dist/plugin/index.d.ts", + "import": "./dist/plugin/index.js", + "require": "./dist/plugin/index.cjs" + }, + "./internal/global-setup": { + "types": "./dist/plugin/global-setup.d.ts", + "import": "./dist/plugin/global-setup.js", + "require": "./dist/plugin/global-setup.cjs" + }, + "./internal/setup-file": { + "types": "./dist/plugin/setup-file.d.ts", + "import": "./dist/plugin/setup-file.js" + }, + "./internal/test-utils": { + "types": "./dist/plugin/test-utils.d.ts", + "import": "./dist/plugin/test-utils.js", + "require": "./dist/plugin/test-utils.cjs" + }, + "./manager": "./dist/manager.js", + "./preset": "./dist/preset.cjs", + "./postinstall": "./dist/postinstall.cjs", + "./package.json": "./package.json" + }, + "main": "dist/index.cjs", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist/**/*", + "README.md", + "*.mjs", + "*.js", + "*.cjs", + "*.d.ts", + "!src/**/*" + ], + "scripts": { + "check": "jiti ../../../scripts/prepare/check.ts", + "prep": "jiti ../../../scripts/prepare/addon-bundle.ts" + }, + "dependencies": { + "@storybook/csf": "^0.1.11" + }, + "devDependencies": { + "@vitest/browser": "^2.0.0", + "vitest": "^2.0.0" + }, + "peerDependencies": { + "@vitest/browser": "^2.0.0", + "storybook": "workspace:^", + "vitest": "^2.0.0" + }, + "publishConfig": { + "access": "public" + }, + "bundler": { + "exportEntries": [ + "./src/index.ts", + "./src/plugin/test-utils.ts", + "./src/plugin/setup-file.ts" + ], + "managerEntries": [ + "./src/manager.tsx" + ], + "nodeEntries": [ + "./src/preset.ts", + "./src/plugin/index.ts", + "./src/plugin/global-setup.ts", + "./src/postinstall.ts" + ] + } +} diff --git a/code/addons/vitest/postinstall.cjs b/code/addons/vitest/postinstall.cjs new file mode 100644 index 000000000000..4a50bbe539e3 --- /dev/null +++ b/code/addons/vitest/postinstall.cjs @@ -0,0 +1 @@ +module.exports = require('./dist/postinstall.js'); diff --git a/code/addons/vitest/preset.cjs b/code/addons/vitest/preset.cjs new file mode 100644 index 000000000000..87f1602c2f26 --- /dev/null +++ b/code/addons/vitest/preset.cjs @@ -0,0 +1,8 @@ +function managerEntries(entry = []) { + return [...entry, require.resolve('./dist/manager.js')]; +} + +module.exports = { + managerEntries, + ...require('./dist/preset'), +}; diff --git a/code/addons/vitest/project.json b/code/addons/vitest/project.json new file mode 100644 index 000000000000..71cca948ed01 --- /dev/null +++ b/code/addons/vitest/project.json @@ -0,0 +1,8 @@ +{ + "name": "vitest", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "targets": { + "build": {} + } +} diff --git a/code/addons/vitest/src/constants.ts b/code/addons/vitest/src/constants.ts new file mode 100644 index 000000000000..d0a3762620c9 --- /dev/null +++ b/code/addons/vitest/src/constants.ts @@ -0,0 +1 @@ +export const ADDON_ID = 'storybook/vitest'; diff --git a/code/addons/vitest/src/index.ts b/code/addons/vitest/src/index.ts new file mode 100644 index 000000000000..dafa948eda6c --- /dev/null +++ b/code/addons/vitest/src/index.ts @@ -0,0 +1,2 @@ +// make it work with --isolatedModules +export default {}; diff --git a/code/addons/vitest/src/manager.tsx b/code/addons/vitest/src/manager.tsx new file mode 100644 index 000000000000..8d706325046a --- /dev/null +++ b/code/addons/vitest/src/manager.tsx @@ -0,0 +1,5 @@ +import { type API, addons } from 'storybook/internal/manager-api'; + +import { ADDON_ID } from './constants'; + +addons.register(ADDON_ID, () => {}); diff --git a/code/addons/vitest/src/plugin/global-setup.ts b/code/addons/vitest/src/plugin/global-setup.ts new file mode 100644 index 000000000000..ac3a5f5a8fcd --- /dev/null +++ b/code/addons/vitest/src/plugin/global-setup.ts @@ -0,0 +1,69 @@ +/* eslint-disable no-underscore-dangle */ +import { type ChildProcess, spawn } from 'node:child_process'; + +import type { GlobalSetupContext } from 'vitest/node'; + +import { logger } from 'storybook/internal/node-logger'; + +let storybookProcess: ChildProcess | null = null; + +const checkStorybookRunning = async (storybookUrl: string): Promise => { + try { + const response = await fetch(`${storybookUrl}/iframe.html`, { method: 'HEAD' }); + return response.ok; + } catch { + return false; + } +}; + +const startStorybookIfNotRunning = async () => { + const storybookScript = process.env.__STORYBOOK_SCRIPT__ as string; + const storybookUrl = process.env.__STORYBOOK_URL__ as string; + + const isRunning = await checkStorybookRunning(storybookUrl); + + if (isRunning) { + logger.verbose('Storybook is already running'); + return; + } + + logger.verbose(`Starting Storybook with command: ${storybookScript}`); + + try { + // We don't await the process because we don't want Vitest to hang while Storybook is starting + storybookProcess = spawn(storybookScript, [], { + stdio: process.env.DEBUG === 'storybook' ? 'pipe' : 'ignore', + cwd: process.cwd(), + shell: true, + }); + + storybookProcess.on('error', (error) => { + logger.verbose('Failed to start Storybook:' + error.message); + throw error; + }); + } catch (error: unknown) { + logger.verbose('Failed to start Storybook:' + (error as any).message); + throw error; + } +}; + +const killProcess = (process: ChildProcess) => { + return new Promise((resolve, reject) => { + process.on('close', resolve); + process.on('error', reject); + process.kill(); + }); +}; + +export const setup = async ({ config }: GlobalSetupContext) => { + if (config.watch) { + await startStorybookIfNotRunning(); + } +}; + +export const teardown = async () => { + if (storybookProcess) { + logger.verbose('Stopping Storybook process'); + await killProcess(storybookProcess); + } +}; diff --git a/code/addons/vitest/src/plugin/index.ts b/code/addons/vitest/src/plugin/index.ts new file mode 100644 index 000000000000..b607ce551df2 --- /dev/null +++ b/code/addons/vitest/src/plugin/index.ts @@ -0,0 +1,128 @@ +/* eslint-disable no-underscore-dangle */ +import { join, resolve } from 'node:path'; + +import type { Plugin } from 'vitest/config'; + +import { loadAllPresets, validateConfigurationFiles } from 'storybook/internal/common'; +import { vitestTransform } from 'storybook/internal/csf-tools'; +import { MainFileMissingError } from 'storybook/internal/server-errors'; +import type { StoriesEntry } from 'storybook/internal/types'; + +import type { InternalOptions, UserOptions } from './types'; + +const defaultOptions: UserOptions = { + storybookScript: undefined, + configDir: undefined, + storybookUrl: 'http://localhost:6006', +}; + +export const storybookTest = (options?: UserOptions): Plugin => { + const finalOptions = { + ...defaultOptions, + ...options, + tags: { + include: options?.tags?.include ?? ['test'], + exclude: options?.tags?.exclude ?? [], + skip: options?.tags?.skip ?? [], + }, + } as InternalOptions; + + if (process.env.DEBUG) { + finalOptions.debug = true; + } + + const storybookUrl = finalOptions.storybookUrl || defaultOptions.storybookUrl; + + // To be accessed by the global setup file + process.env.__STORYBOOK_URL__ = storybookUrl; + process.env.__STORYBOOK_SCRIPT__ = finalOptions.storybookScript; + + let stories: StoriesEntry[]; + + if (!finalOptions.configDir) { + finalOptions.configDir = resolve(join(process.cwd(), '.storybook')); + } else { + finalOptions.configDir = resolve(process.cwd(), finalOptions.configDir); + } + + return { + name: 'vite-plugin-storybook-test', + enforce: 'pre', + async buildStart() { + try { + await validateConfigurationFiles(finalOptions.configDir); + } catch (err) { + throw new MainFileMissingError({ + location: finalOptions.configDir, + source: 'vitest', + }); + } + + const presets = await loadAllPresets({ + configDir: finalOptions.configDir, + corePresets: [], + overridePresets: [], + packageJson: {}, + }); + + stories = await presets.apply('stories', []); + }, + async config(config) { + // If we end up needing to know if we are running in browser mode later + // const isRunningInBrowserMode = config.plugins.find((plugin: Plugin) => + // plugin.name?.startsWith('vitest:browser') + // ) + config.test ??= {}; + + config.test.env ??= {}; + config.test.env = { + ...config.test.env, + // To be accessed by the setup file + __STORYBOOK_URL__: storybookUrl, + }; + + config.resolve ??= {}; + config.resolve.conditions ??= []; + config.resolve.conditions.push('storybook', 'stories', 'test'); + + config.test.setupFiles ??= []; + if (typeof config.test.setupFiles === 'string') { + config.test.setupFiles = [config.test.setupFiles]; + } + config.test.setupFiles.push('@storybook/experimental-addon-vitest/internal/setup-file'); + + // when a Storybook script is provided, we spawn Storybook for the user when in watch mode + if (finalOptions.storybookScript) { + config.test.globalSetup = config.test.globalSetup ?? []; + if (typeof config.test.globalSetup === 'string') { + config.test.globalSetup = [config.test.globalSetup]; + } + config.test.globalSetup.push('@storybook/experimental-addon-vitest/internal/global-setup'); + } + + config.test.server ??= {}; + config.test.server.deps ??= {}; + config.test.server.deps.inline ??= []; + if (Array.isArray(config.test.server.deps.inline)) { + config.test.server.deps.inline.push('@storybook/experimental-addon-vitest'); + } + }, + async transform(code, id) { + if (process.env.VITEST !== 'true') { + return code; + } + + if (id.match(/(story|stories)\.[cm]?[jt]sx?$/)) { + return vitestTransform({ + code, + fileName: id, + configDir: finalOptions.configDir, + tagsFilter: finalOptions.tags, + stories, + }); + } + }, + }; +}; + +export default storybookTest; diff --git a/code/addons/vitest/src/plugin/setup-file.ts b/code/addons/vitest/src/plugin/setup-file.ts new file mode 100644 index 000000000000..b5f411e97671 --- /dev/null +++ b/code/addons/vitest/src/plugin/setup-file.ts @@ -0,0 +1,40 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + +/* eslint-disable no-underscore-dangle */ +import { afterAll, vi } from 'vitest'; +import type { RunnerTask, TaskMeta } from 'vitest'; + +import { Channel } from 'storybook/internal/channels'; + +declare global { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - The module is augmented elsewhere but we need to duplicate it to avoid issues in no-link mode. + // eslint-disable-next-line no-var + var __STORYBOOK_ADDONS_CHANNEL__: Channel; +} + +type ExtendedMeta = TaskMeta & { storyId: string; hasPlayFunction: boolean }; + +const transport = { setHandler: vi.fn(), send: vi.fn() }; +globalThis.__STORYBOOK_ADDONS_CHANNEL__ = new Channel({ transport }); + +// The purpose of this set up file is to modify the error message of failed tests +// and inject a link to the story in Storybook +const modifyErrorMessage = (currentTask: RunnerTask) => { + const meta = currentTask.meta as ExtendedMeta; + if ( + currentTask.type === 'test' && + currentTask.result?.state === 'fail' && + meta.storyId && + currentTask.result.errors?.[0] + ) { + const currentError = currentTask.result.errors[0]; + const storybookUrl = import.meta.env.__STORYBOOK_URL__; + const storyUrl = `${storybookUrl}/?path=/story/${meta.storyId}&addonPanel=storybook/interactions/panel`; + currentError.message = `\n\x1B[34mClick to debug the error directly in Storybook: ${storyUrl}\x1B[39m\n\n${currentError.message}`; + } +}; + +afterAll((suite) => { + suite.tasks.forEach(modifyErrorMessage); +}); diff --git a/code/addons/vitest/src/plugin/test-utils.ts b/code/addons/vitest/src/plugin/test-utils.ts new file mode 100644 index 000000000000..a86a178cc429 --- /dev/null +++ b/code/addons/vitest/src/plugin/test-utils.ts @@ -0,0 +1,35 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + +/* eslint-disable no-underscore-dangle */ +import { type RunnerTask, type TaskContext, type TaskMeta, type TestContext } from 'vitest'; + +import type { ComposedStoryFn } from 'storybook/internal/types'; + +import type { UserOptions } from './types'; +import { setViewport } from './viewports'; + +type TagsFilter = Required; + +export const isValidTest = (storyTags: string[], tagsFilter: TagsFilter) => { + const isIncluded = + tagsFilter?.include.length === 0 || tagsFilter?.include.some((tag) => storyTags.includes(tag)); + const isNotExcluded = tagsFilter?.exclude.every((tag) => !storyTags.includes(tag)); + + return isIncluded && isNotExcluded; +}; + +export const testStory = (Story: ComposedStoryFn, tagsFilter: TagsFilter) => { + return async (context: TestContext & TaskContext & { story: ComposedStoryFn }) => { + if (Story === undefined || tagsFilter?.skip.some((tag) => Story.tags.includes(tag))) { + context.skip(); + } + + context.story = Story; + + const _task = context.task as RunnerTask & { meta: TaskMeta & { storyId: string } }; + _task.meta.storyId = Story.id; + + await setViewport(Story.parameters.viewport); + await Story.run(); + }; +}; diff --git a/code/addons/vitest/src/plugin/types.ts b/code/addons/vitest/src/plugin/types.ts new file mode 100644 index 000000000000..a339a7e694f5 --- /dev/null +++ b/code/addons/vitest/src/plugin/types.ts @@ -0,0 +1,33 @@ +export type UserOptions = { + /** + * The directory where the Storybook configuration is located, relative to the vitest configuration file. + * If not provided, the plugin will use '.storybook' in the current working directory. + * @default '.storybook' + */ + configDir?: string; + /** + * Optional script to run Storybook. + * If provided, Vitest will start Storybook using this script when ran in watch mode. + * @default undefined + */ + storybookScript?: string; + /** + * The URL where Storybook is hosted. + * This is used to provide a link to the story in the test output on failures. + * @default 'http://localhost:6006' + */ + storybookUrl?: string; + /** + * Tags to include, exclude, or skip. These tags are defined as annotations in your story or meta. + */ + tags?: { + include?: string[]; + exclude?: string[]; + skip?: string[]; + }; +}; + +export type InternalOptions = Required & { + debug: boolean; + tags: Required; +}; diff --git a/code/addons/vitest/src/plugin/viewports.ts b/code/addons/vitest/src/plugin/viewports.ts new file mode 100644 index 000000000000..33312ecbbe4d --- /dev/null +++ b/code/addons/vitest/src/plugin/viewports.ts @@ -0,0 +1,38 @@ +/* eslint-disable no-underscore-dangle */ +import { page } from '@vitest/browser/context'; + +import { INITIAL_VIEWPORTS } from '../../../viewport/src/defaults'; +import type { ViewportMap, ViewportStyles } from '../../../viewport/src/types'; + +declare global { + // eslint-disable-next-line no-var, @typescript-eslint/naming-convention + var __vitest_browser__: boolean; +} + +interface ViewportsParam { + defaultViewport: string; + viewports: ViewportMap; +} + +export const setViewport = async (viewportsParam: ViewportsParam = {} as ViewportsParam) => { + const defaultViewport = viewportsParam.defaultViewport; + if (!page || !globalThis.__vitest_browser__ || !defaultViewport) return null; + + const viewports = { + ...INITIAL_VIEWPORTS, + ...viewportsParam.viewports, + }; + + if (defaultViewport in viewports) { + const styles = viewports[defaultViewport].styles as ViewportStyles; + if (styles?.width && styles?.height) { + const { width, height } = { + width: Number.parseInt(styles.width, 10), + height: Number.parseInt(styles.height, 10), + }; + await page.viewport(width, height); + } + } + + return null; +}; diff --git a/code/addons/vitest/src/postinstall.ts b/code/addons/vitest/src/postinstall.ts new file mode 100644 index 000000000000..47bcad1f9349 --- /dev/null +++ b/code/addons/vitest/src/postinstall.ts @@ -0,0 +1,3 @@ +export default async function postinstall(context: any) { + console.log('[addon-vitest] postinstall with', context); +} diff --git a/code/addons/vitest/src/preset.ts b/code/addons/vitest/src/preset.ts new file mode 100644 index 000000000000..8b4bc31a9013 --- /dev/null +++ b/code/addons/vitest/src/preset.ts @@ -0,0 +1,7 @@ +import type { Channel } from 'storybook/internal/channels'; +import type { Options } from 'storybook/internal/types'; + +// eslint-disable-next-line @typescript-eslint/naming-convention +export const experimental_serverChannel = async (channel: Channel, options: Options) => { + return channel; +}; diff --git a/code/addons/vitest/tsconfig.json b/code/addons/vitest/tsconfig.json new file mode 100644 index 000000000000..e4e3d59266c4 --- /dev/null +++ b/code/addons/vitest/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "../../../", + "module": "Preserve", + "moduleResolution": "Bundler", + "types": ["vitest"] + }, + "include": ["src/**/*", "./typings.d.ts"] +} diff --git a/code/addons/vitest/typings.d.ts b/code/addons/vitest/typings.d.ts new file mode 100644 index 000000000000..ec1d9d7d3808 --- /dev/null +++ b/code/addons/vitest/typings.d.ts @@ -0,0 +1,7 @@ +interface ImportMetaEnv { + __STORYBOOK_URL__?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/code/addons/vitest/vitest.config.ts b/code/addons/vitest/vitest.config.ts new file mode 100644 index 000000000000..7420176b2e46 --- /dev/null +++ b/code/addons/vitest/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig, mergeConfig } from 'vitest/config'; + +import { vitestCommonConfig } from '../../vitest.workspace'; + +export default mergeConfig( + vitestCommonConfig, + defineConfig({ + // Add custom config here + }) +); diff --git a/code/core/package.json b/code/core/package.json index 52b4ea26a984..0d139263b13b 100644 --- a/code/core/package.json +++ b/code/core/package.json @@ -302,7 +302,7 @@ "@radix-ui/react-slot": "^1.0.2", "@storybook/docs-mdx": "4.0.0-next.1", "@storybook/global": "^5.0.0", - "@storybook/icons": "^1.2.5", + "@storybook/icons": "^1.2.10", "@tanstack/react-virtual": "^3.3.0", "@testing-library/react": "^14.0.0", "@types/compression": "^1.7.0", @@ -397,6 +397,7 @@ "require-from-string": "^2.0.2", "resolve-from": "^5.0.0", "slash": "^5.0.0", + "source-map": "^0.7.4", "store2": "^2.14.2", "strip-json-comments": "^5.0.1", "telejson": "^7.2.0", diff --git a/code/core/src/common/index.ts b/code/core/src/common/index.ts index 107ce9b6d98f..0e7e92bd6a71 100644 --- a/code/core/src/common/index.ts +++ b/code/core/src/common/index.ts @@ -40,6 +40,8 @@ export * from './utils/validate-configuration-files'; export * from './utils/satisfies'; export * from './utils/strip-abs-node-modules-path'; export * from './utils/formatter'; +export * from './utils/get-story-id'; +export * from './utils/posix'; export * from './js-package-manager'; export { versions }; diff --git a/code/core/src/core-server/utils/get-story-id.test.ts b/code/core/src/common/utils/get-story-id.test.ts similarity index 100% rename from code/core/src/core-server/utils/get-story-id.test.ts rename to code/core/src/common/utils/get-story-id.test.ts diff --git a/code/core/src/core-server/utils/get-story-id.ts b/code/core/src/common/utils/get-story-id.ts similarity index 69% rename from code/core/src/core-server/utils/get-story-id.ts rename to code/core/src/common/utils/get-story-id.ts index 7dad86006b97..2275cdfcf8ce 100644 --- a/code/core/src/core-server/utils/get-story-id.ts +++ b/code/core/src/common/utils/get-story-id.ts @@ -1,7 +1,7 @@ import { relative } from 'node:path'; import { normalizeStories, normalizeStoryPath } from '@storybook/core/common'; -import type { Options } from '@storybook/core/types'; +import type { Options, StoriesEntry } from '@storybook/core/types'; import { sanitize, storyNameFromExport, toId } from '@storybook/csf'; import { userOrAutoTitleFromSpecifier } from '@storybook/core/preview-api'; @@ -15,23 +15,23 @@ interface StoryIdData { exportedStoryName: string; } +type GetStoryIdOptions = StoryIdData & { + configDir: string; + stories: StoriesEntry[]; + workingDir?: string; + userTitle?: string; + storyFilePath: string; +}; + export async function getStoryId(data: StoryIdData, options: Options) { const stories = await options.presets.apply('stories', [], options); - const workingDir = process.cwd(); - - const normalizedStories = normalizeStories(stories, { + const autoTitle = getStoryTitle({ + ...data, + stories, configDir: options.configDir, - workingDir, }); - const relativePath = relative(workingDir, data.storyFilePath); - const importPath = posix(normalizeStoryPath(relativePath)); - - const autoTitle = normalizedStories - .map((normalizeStory) => userOrAutoTitleFromSpecifier(importPath, normalizeStory)) - .filter(Boolean)[0]; - if (autoTitle === undefined) { // eslint-disable-next-line local-rules/no-uncategorized-errors throw new Error(dedent` @@ -46,3 +46,23 @@ export async function getStoryId(data: StoryIdData, options: Options) { return { storyId, kind }; } + +export function getStoryTitle({ + storyFilePath, + configDir, + stories, + workingDir = process.cwd(), + userTitle, +}: Omit) { + const normalizedStories = normalizeStories(stories, { + configDir, + workingDir, + }); + + const relativePath = relative(workingDir, storyFilePath); + const importPath = posix(normalizeStoryPath(relativePath)); + + return normalizedStories + .map((normalizeStory) => userOrAutoTitleFromSpecifier(importPath, normalizeStory, userTitle)) + .filter(Boolean)[0]; +} diff --git a/code/core/src/core-server/utils/posix.test.ts b/code/core/src/common/utils/posix.test.ts similarity index 100% rename from code/core/src/core-server/utils/posix.test.ts rename to code/core/src/common/utils/posix.test.ts diff --git a/code/core/src/core-server/utils/posix.ts b/code/core/src/common/utils/posix.ts similarity index 100% rename from code/core/src/core-server/utils/posix.ts rename to code/core/src/common/utils/posix.ts diff --git a/code/core/src/common/versions.ts b/code/core/src/common/versions.ts index 2ba4066000a5..1b6a9301d2ea 100644 --- a/code/core/src/common/versions.ts +++ b/code/core/src/common/versions.ts @@ -18,6 +18,7 @@ export default { '@storybook/addon-themes': '8.3.0-alpha.5', '@storybook/addon-toolbars': '8.3.0-alpha.5', '@storybook/addon-viewport': '8.3.0-alpha.5', + '@storybook/experimental-addon-vitest': '8.3.0-alpha.5', '@storybook/builder-vite': '8.3.0-alpha.5', '@storybook/builder-webpack5': '8.3.0-alpha.5', '@storybook/core': '8.3.0-alpha.5', diff --git a/code/core/src/components/components/tooltip/ListItem.tsx b/code/core/src/components/components/tooltip/ListItem.tsx index 9e4dc9d75f9b..068ab88d59c2 100644 --- a/code/core/src/components/components/tooltip/ListItem.tsx +++ b/code/core/src/components/components/tooltip/ListItem.tsx @@ -1,4 +1,4 @@ -import type { ComponentProps, ReactNode } from 'react'; +import type { ComponentProps, ReactNode, SyntheticEvent } from 'react'; import React from 'react'; import { styled } from '@storybook/core/theming'; @@ -115,15 +115,19 @@ const Left = styled.span( export interface ItemProps { disabled?: boolean; + href?: string; + onClick?: (event: SyntheticEvent, ...args: any[]) => any; } -const Item = styled.a( +const Item = styled.div( ({ theme }) => ({ + width: '100%', + border: 'none', + background: 'none', fontSize: theme.typography.size.s1, transition: 'all 150ms ease-out', color: theme.color.dark, textDecoration: 'none', - cursor: 'pointer', justifyContent: 'space-between', lineHeight: '18px', @@ -134,43 +138,34 @@ const Item = styled.a( '& > * + *': { paddingLeft: 10, }, - - '&:hover': { - background: theme.background.hoverable, - }, - '&:hover svg': { - opacity: 1, - }, }), - ({ disabled }) => - disabled - ? { - cursor: 'not-allowed', - } - : {} + ({ theme, href, onClick }) => + (href || onClick) && { + cursor: 'pointer', + '&:hover': { + background: theme.background.hoverable, + }, + '&:hover svg': { + opacity: 1, + }, + }, + ({ disabled }) => disabled && { cursor: 'not-allowed' } ); -const getItemProps = memoize(100)((onClick, href, LinkWrapper) => { - const result = {}; - - if (onClick) { - Object.assign(result, { - onClick, - }); - } - if (href) { - Object.assign(result, { - href, - }); - } - if (LinkWrapper && href) { - Object.assign(result, { - to: href, +const getItemProps = memoize(100)((onClick, href, LinkWrapper) => ({ + ...(onClick && { + as: 'button', + onClick, + }), + ...(href && { + as: 'a', + href, + ...(LinkWrapper && { as: LinkWrapper, - }); - } - return result; -}); + to: href, + }), + }), +})); export type LinkWrapperType = (props: any) => ReactNode; @@ -202,23 +197,25 @@ const ListItem = ({ LinkWrapper = undefined, ...rest }: ListItemProps) => { - const itemProps = getItemProps(onClick, href, LinkWrapper); const commonProps = { active, disabled }; + const itemProps = getItemProps(onClick, href, LinkWrapper); return ( - - {icon && {icon}} - {title || center ? ( -
- {title && ( - - {title} - - )} - {center && {center}} -
- ) : null} - {right && {right}} + + <> + {icon && {icon}} + {title || center ? ( +
+ {title && ( + + {title} + + )} + {center && {center}} +
+ ) : null} + {right && {right}} +
); }; diff --git a/code/core/src/components/components/tooltip/TooltipLinkList.stories.tsx b/code/core/src/components/components/tooltip/TooltipLinkList.stories.tsx index 07f870cc1f24..28952285756d 100644 --- a/code/core/src/components/components/tooltip/TooltipLinkList.stories.tsx +++ b/code/core/src/components/components/tooltip/TooltipLinkList.stories.tsx @@ -1,5 +1,4 @@ -import type { FunctionComponent, MouseEvent, PropsWithChildren, ReactElement } from 'react'; -import React, { Children, cloneElement } from 'react'; +import React from 'react'; import { LinkIcon, LinuxIcon } from '@storybook/icons'; import type { Meta, StoryObj } from '@storybook/react'; @@ -12,26 +11,6 @@ import ellipseUrl from './assets/ellipse.png'; const onLinkClick = action('onLinkClick'); -interface StoryLinkWrapperProps { - href: string; - passHref?: boolean; -} - -const StoryLinkWrapper: FunctionComponent> = ({ - href, - passHref = false, - children, -}) => { - const child = Children.only(children) as ReactElement; - return cloneElement(child, { - href: passHref && href, - onClick: (e: MouseEvent) => { - e.preventDefault(); - onLinkClick(href); - }, - }); -}; - export default { component: TooltipLinkList, decorators: [ @@ -60,15 +39,16 @@ export const WithoutIcons = { title: 'Link 1', center: 'This is an addition description', href: 'http://google.com', + onClick: onLinkClick, }, { id: '2', title: 'Link 2', center: 'This is an addition description', href: 'http://google.com', + onClick: onLinkClick, }, ], - LinkWrapper: StoryLinkWrapper, }, } satisfies Story; @@ -81,15 +61,16 @@ export const WithOneIcon = { center: 'This is an addition description', icon: , href: 'http://google.com', + onClick: onLinkClick, }, { id: '2', title: 'Link 2', center: 'This is an addition description', href: 'http://google.com', + onClick: onLinkClick, }, ], - LinkWrapper: StoryLinkWrapper, }, } satisfies Story; @@ -102,15 +83,16 @@ export const ActiveWithoutAnyIcons = { active: true, center: 'This is an addition description', href: 'http://google.com', + onClick: onLinkClick, }, { id: '2', title: 'Link 2', center: 'This is an addition description', href: 'http://google.com', + onClick: onLinkClick, }, ], - LinkWrapper: StoryLinkWrapper, }, } satisfies Story; @@ -123,6 +105,7 @@ export const ActiveWithSeparateIcon = { icon: , center: 'This is an addition description', href: 'http://google.com', + onClick: onLinkClick, }, { id: '2', @@ -130,9 +113,9 @@ export const ActiveWithSeparateIcon = { active: true, center: 'This is an addition description', href: 'http://google.com', + onClick: onLinkClick, }, ], - LinkWrapper: StoryLinkWrapper, }, } satisfies Story; @@ -146,15 +129,16 @@ export const ActiveAndIcon = { icon: , center: 'This is an addition description', href: 'http://google.com', + onClick: onLinkClick, }, { id: '2', title: 'Link 2', center: 'This is an addition description', href: 'http://google.com', + onClick: onLinkClick, }, ], - LinkWrapper: StoryLinkWrapper, }, } satisfies Story; @@ -169,6 +153,7 @@ export const WithIllustration = { right: ellipse, center: 'This is an addition description', href: 'http://google.com', + onClick: onLinkClick, }, { id: '2', @@ -176,9 +161,9 @@ export const WithIllustration = { center: 'This is an addition description', right: ellipse, href: 'http://google.com', + onClick: onLinkClick, }, ], - LinkWrapper: StoryLinkWrapper, }, } satisfies Story; @@ -193,6 +178,7 @@ export const WithCustomIcon = { right: ellipse, center: 'This is an addition description', href: 'http://google.com', + onClick: onLinkClick, }, { id: '2', @@ -200,8 +186,8 @@ export const WithCustomIcon = { center: 'This is an addition description', right: ellipse, href: 'http://google.com', + onClick: onLinkClick, }, ], - LinkWrapper: StoryLinkWrapper, }, } satisfies Story; diff --git a/code/core/src/components/components/tooltip/TooltipLinkList.tsx b/code/core/src/components/components/tooltip/TooltipLinkList.tsx index 7ab5fabf1961..51540335d034 100644 --- a/code/core/src/components/components/tooltip/TooltipLinkList.tsx +++ b/code/core/src/components/components/tooltip/TooltipLinkList.tsx @@ -1,4 +1,4 @@ -import type { SyntheticEvent } from 'react'; +import type { ComponentProps, SyntheticEvent } from 'react'; import React, { useCallback } from 'react'; import { styled } from '@storybook/core/theming'; @@ -20,54 +20,38 @@ const List = styled.div( export interface Link extends Omit { id: string; - isGatsby?: boolean; - onClick?: (event: SyntheticEvent, item: ListItemProps) => void; + onClick?: ( + event: SyntheticEvent, + item: Pick + ) => void; } interface ItemProps extends Link { isIndented?: boolean; } -const Item = (props: ItemProps) => { - const { LinkWrapper, onClick: onClickFromProps, id, isIndented, ...rest } = props; - const { title, href, active } = rest; - const onClick = useCallback( - (event: SyntheticEvent) => { - // @ts-expect-error (non strict) - onClickFromProps(event, rest); - }, - [onClickFromProps] - ); - - const hasOnClick = !!onClickFromProps; +const Item = ({ id, onClick, ...rest }: ItemProps) => { + const { active, disabled, title } = rest; - return ( - + const handleClick = useCallback( + (event: SyntheticEvent) => onClick?.(event, { id, active, disabled, title }), + [onClick, id, active, disabled, title] ); + + return ; }; -export interface TooltipLinkListProps { +export interface TooltipLinkListProps extends ComponentProps { links: Link[]; LinkWrapper?: LinkWrapperType; } -// @ts-expect-error (non strict) -export const TooltipLinkList = ({ links, LinkWrapper = null }: TooltipLinkListProps) => { - const hasIcon = links.some((link) => link.icon); +export const TooltipLinkList = ({ links, LinkWrapper, ...props }: TooltipLinkListProps) => { + const isIndented = links.some((link) => link.icon); return ( - - {links.map(({ isGatsby, ...p }) => ( - // @ts-expect-error (non strict) - + + {links.map((link) => ( + ))} ); diff --git a/code/core/src/core-events/index.ts b/code/core/src/core-events/index.ts index cbb1bef3c6af..90eed7b0d5b6 100644 --- a/code/core/src/core-events/index.ts +++ b/code/core/src/core-events/index.ts @@ -47,6 +47,8 @@ enum events { STORY_ARGS_UPDATED = 'storyArgsUpdated', // Reset either a single arg of a story all args of a story RESET_STORY_ARGS = 'resetStoryArgs', + // Emitted after a filter is set + SET_FILTER = 'setFilter', // Emitted by the preview at startup once it knows the initial set of globals+globalTypes SET_GLOBALS = 'setGlobals', // Tell the preview to update the value of a global @@ -114,6 +116,7 @@ export const { SELECT_STORY, SET_CONFIG, SET_CURRENT_STORY, + SET_FILTER, SET_GLOBALS, SET_INDEX, SET_STORIES, diff --git a/code/core/src/core-server/server-channel/create-new-story-channel.ts b/code/core/src/core-server/server-channel/create-new-story-channel.ts index a24dda123806..4a77720cc193 100644 --- a/code/core/src/core-server/server-channel/create-new-story-channel.ts +++ b/code/core/src/core-server/server-channel/create-new-story-channel.ts @@ -3,6 +3,7 @@ import { writeFile } from 'node:fs/promises'; import { relative } from 'node:path'; import type { Channel } from '@storybook/core/channels'; +import { getStoryId } from '@storybook/core/common'; import { telemetry } from '@storybook/core/telemetry'; import type { CoreConfig, Options } from '@storybook/core/types'; @@ -19,7 +20,6 @@ import { } from '@storybook/core/core-events'; import { getNewStoryFile } from '../utils/get-new-story-file'; -import { getStoryId } from '../utils/get-story-id'; export function initCreateNewStoryChannel( channel: Channel, diff --git a/code/core/src/core-server/server-channel/file-search-channel.test.ts b/code/core/src/core-server/server-channel/file-search-channel.test.ts index 5594b8a844b4..64728b142e88 100644 --- a/code/core/src/core-server/server-channel/file-search-channel.test.ts +++ b/code/core/src/core-server/server-channel/file-search-channel.test.ts @@ -3,6 +3,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { ChannelTransport } from '@storybook/core/channels'; import { Channel } from '@storybook/core/channels'; +import { + extractProperRendererNameFromFramework, + getFrameworkName, + getProjectRoot, +} from '@storybook/core/common'; import type { FileComponentSearchRequestPayload, RequestData } from '@storybook/core/core-events'; import { @@ -10,30 +15,22 @@ import { FILE_COMPONENT_SEARCH_RESPONSE, } from '@storybook/core/core-events'; +import { searchFiles } from '../utils/search-files'; import { initFileSearchChannel } from './file-search-channel'; -const mocks = vi.hoisted(() => { - return { - searchFiles: vi.fn(), - }; -}); +vi.mock(import('../utils/search-files'), async (importOriginal) => ({ + searchFiles: vi.fn((await importOriginal()).searchFiles), +})); -vi.mock('../utils/search-files', () => { - return { - searchFiles: mocks.searchFiles, - }; -}); +vi.mock('@storybook/core/common'); -vi.mock('@storybook/core/common', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - getFrameworkName: vi.fn().mockResolvedValue('@storybook/react'), - extractProperRendererNameFromFramework: vi.fn().mockResolvedValue('react'), - getProjectRoot: vi - .fn() - .mockReturnValue(require('path').join(__dirname, '..', 'utils', '__search-files-tests__')), - }; +beforeEach(() => { + vi.restoreAllMocks(); + vi.mocked(getFrameworkName).mockResolvedValue('@storybook/react'); + vi.mocked(extractProperRendererNameFromFramework).mockResolvedValue('react'); + vi.mocked(getProjectRoot).mockReturnValue( + require('path').join(__dirname, '..', 'utils', '__search-files-tests__') + ); }); describe('file-search-channel', () => { @@ -41,18 +38,12 @@ describe('file-search-channel', () => { const mockChannel = new Channel({ transport }); const searchResultChannelListener = vi.fn(); - beforeEach(() => { - transport.setHandler.mockClear(); - transport.send.mockClear(); - searchResultChannelListener.mockClear(); - }); - describe('initFileSearchChannel', async () => { - it('should emit search result event with the search result', async () => { + it('should emit search result event with the search result', { timeout: 10000 }, async () => { const mockOptions = {}; const data = { searchQuery: 'es-module' }; - initFileSearchChannel(mockChannel, mockOptions as any, { disableTelemetry: true }); + await initFileSearchChannel(mockChannel, mockOptions as any, { disableTelemetry: true }); mockChannel.addListener(FILE_COMPONENT_SEARCH_RESPONSE, searchResultChannelListener); mockChannel.emit(FILE_COMPONENT_SEARCH_REQUEST, { @@ -60,18 +51,10 @@ describe('file-search-channel', () => { payload: {}, } satisfies RequestData); - mocks.searchFiles.mockImplementation(async (...args) => { - // @ts-expect-error Ignore type issue - return (await vi.importActual('../utils/search-files')).searchFiles(...args); + await vi.waitFor(() => expect(searchResultChannelListener).toHaveBeenCalled(), { + timeout: 8000, }); - await vi.waitFor( - () => { - expect(searchResultChannelListener).toHaveBeenCalled(); - }, - { timeout: 2000 } - ); - expect(searchResultChannelListener).toHaveBeenCalledWith({ id: data.searchQuery, error: null, @@ -113,58 +96,56 @@ describe('file-search-channel', () => { }); }); - it('should emit search result event with an empty search result', async () => { - const mockOptions = {}; - const data = { searchQuery: 'no-file-for-search-query' }; - - initFileSearchChannel(mockChannel, mockOptions as any, { disableTelemetry: true }); - - mockChannel.addListener(FILE_COMPONENT_SEARCH_RESPONSE, searchResultChannelListener); - mockChannel.emit(FILE_COMPONENT_SEARCH_REQUEST, { - id: data.searchQuery, - payload: {}, - } satisfies RequestData); - - mocks.searchFiles.mockImplementation(async (...args) => { - // @ts-expect-error Ignore type issue - return (await vi.importActual('../utils/search-files')).searchFiles(...args); - }); - - await vi.waitFor( - () => { - expect(searchResultChannelListener).toHaveBeenCalled(); - }, - { timeout: 2000 } - ); - - expect(searchResultChannelListener).toHaveBeenCalledWith({ - id: data.searchQuery, - error: null, - payload: { - files: [], - }, - success: true, - }); - }); + it( + 'should emit search result event with an empty search result', + { timeout: 10000 }, + async () => { + const mockOptions = {}; + const data = { searchQuery: 'no-file-for-search-query' }; + + await initFileSearchChannel(mockChannel, mockOptions as any, { disableTelemetry: true }); + + mockChannel.addListener(FILE_COMPONENT_SEARCH_RESPONSE, searchResultChannelListener); + mockChannel.emit(FILE_COMPONENT_SEARCH_REQUEST, { + id: data.searchQuery, + payload: {}, + } satisfies RequestData); + + await vi.waitFor( + () => { + expect(searchResultChannelListener).toHaveBeenCalled(); + }, + { + timeout: 8000, + } + ); + + expect(searchResultChannelListener).toHaveBeenCalledWith({ + id: data.searchQuery, + error: null, + payload: { + files: [], + }, + success: true, + }); + } + ); it('should emit an error message if an error occurs while searching for components in the project', async () => { - const mockOptions = {}; + const mockOptions = {} as any; const data = { searchQuery: 'commonjs' }; - - initFileSearchChannel(mockChannel, mockOptions as any, { disableTelemetry: true }); + await initFileSearchChannel(mockChannel, mockOptions, { disableTelemetry: true }); mockChannel.addListener(FILE_COMPONENT_SEARCH_RESPONSE, searchResultChannelListener); mockChannel.emit(FILE_COMPONENT_SEARCH_REQUEST, { id: data.searchQuery, payload: {}, - } satisfies RequestData); + }); - mocks.searchFiles.mockRejectedValue(new Error('ENOENT: no such file or directory')); + vi.mocked(searchFiles).mockRejectedValue(new Error('ENOENT: no such file or directory')); - await vi.waitFor(() => { - expect(searchResultChannelListener).toHaveBeenCalled(); - }); + await vi.waitFor(() => expect(searchResultChannelListener).toHaveBeenCalled()); expect(searchResultChannelListener).toHaveBeenCalledWith({ id: data.searchQuery, diff --git a/code/core/src/csf-tools/CsfFile.test.ts b/code/core/src/csf-tools/CsfFile.test.ts index f90f25d209f2..18d441e666b8 100644 --- a/code/core/src/csf-tools/CsfFile.test.ts +++ b/code/core/src/csf-tools/CsfFile.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest'; import yaml from 'js-yaml'; import { dedent } from 'ts-dedent'; -import { isModuleMock, loadCsf } from './CsfFile'; +import { type CsfOptions, formatCsf, isModuleMock, loadCsf } from './CsfFile'; expect.addSnapshotSerializer({ print: (val: any) => yaml.dump(val).trimEnd(), @@ -21,52 +21,46 @@ const parse = (code: string, includeParameters?: boolean) => { return { meta, stories: filtered }; }; -// +const transform = (code: string, options: Partial = { makeTitle }) => { + const parsed = loadCsf(code, { ...options, makeTitle }).parse(); + return formatCsf(parsed); +}; describe('CsfFile', () => { describe('basic', () => { - it('args stories', () => { + it('filters out non-story exports', () => { + const code = ` + export default { title: 'foo/bar', excludeStories: ['invalidStory'] }; + export const invalidStory = {}; + export const validStory = {}; + `; + const parsed = loadCsf(code, { makeTitle }).parse(); + expect(Object.keys(parsed._stories)).toEqual(['validStory']); + }); + it('filters out non-story exports', () => { + const code = ` + export default { title: 'foo/bar', excludeStories: ['invalidStory'] }; + export const invalidStory = {}; + export const A = {} + const B = {}; + export { B }; + `; + const parsed = loadCsf(code, { makeTitle }).parse(); + expect(Object.keys(parsed._stories)).toEqual(['A', 'B']); + }); + it('transforms inline default exports to constant declarations', () => { expect( - parse( + transform( dedent` export default { title: 'foo/bar' }; - export const A = () => {}; - export const B = (args) => {}; `, - true + { transformInlineMeta: true } ) ).toMatchInlineSnapshot(` - meta: - title: foo/bar - stories: - - id: foo-bar--a - name: A - parameters: - __isArgsStory: false - __id: foo-bar--a - __stats: - play: false - render: false - loaders: false - beforeEach: false - globals: false - storyFn: true - mount: false - moduleMock: false - - id: foo-bar--b - name: B - parameters: - __isArgsStory: true - __id: foo-bar--b - __stats: - play: false - render: false - loaders: false - beforeEach: false - globals: false - storyFn: true - mount: false - moduleMock: false + "const _meta = { + title: 'foo/bar' + }; + export default _meta;" `); }); diff --git a/code/core/src/csf-tools/CsfFile.ts b/code/core/src/csf-tools/CsfFile.ts index de422f321333..a910a022d34e 100644 --- a/code/core/src/csf-tools/CsfFile.ts +++ b/code/core/src/csf-tools/CsfFile.ts @@ -11,6 +11,8 @@ import type { } from '@storybook/core/types'; import { isExportStory, storyNameFromExport, toId } from '@storybook/csf'; +// @ts-expect-error File is not yet exposed, see https://github.com/babel/babel/issues/11350#issuecomment-644118606 +import { File as BabelFileClass } from '@babel/core'; import bg, { type GeneratorOptions } from '@babel/generator'; import bt from '@babel/traverse'; import * as t from '@babel/types'; @@ -29,6 +31,18 @@ const generate = (bg.default || bg) as typeof bg; const logger = console; +// We add this BabelFile as a temporary workaround to deal with a BabelFileClass "ImportEquals should have a literal source" issue in no link mode with tsup +interface BabelFile { + ast: t.File; + opts: any; + hub: any; + metadata: object; + path: any; + scope: any; + inputMap: object | null; + code: string; +} + function parseIncludeExclude(prop: t.Node) { if (t.isArrayExpression(prop)) { return prop.elements.map((e) => { @@ -140,6 +154,11 @@ const MODULE_MOCK_REGEX = /^[.\/#].*\.mock($|\.[^.]*$)/i; export interface CsfOptions { fileName?: string; makeTitle: (userTitle: string) => string; + /** + * If an inline meta is detected e.g. `export default { title: 'foo' }` + * it will be transformed into a constant format e.g. `export const _meta = { title: 'foo' }; export default _meta;` + */ + transformInlineMeta?: boolean; } export class NoMetaError extends Error { @@ -169,11 +188,11 @@ export interface StaticStory extends Pick string; + _rawComponentPath?: string; _meta?: StaticMeta; @@ -187,7 +206,9 @@ export class CsfFile { _metaNode: t.Expression | undefined; - _storyStatements: Record = {}; + _metaVariableName: string | undefined; + + _storyStatements: Record = {}; _storyAnnotations: Record> = {}; @@ -197,11 +218,25 @@ export class CsfFile { imports: string[]; - constructor(ast: t.File, { fileName, makeTitle }: CsfOptions) { + /** + * @deprecated use `_options.fileName` instead + */ + get _fileName() { + return this._options.fileName; + } + + /** + * @deprecated use `_options.makeTitle` instead + */ + get _makeTitle() { + return this._options.makeTitle; + } + + constructor(ast: t.File, options: CsfOptions, file: BabelFile) { this._ast = ast; - this._fileName = fileName as string; + this._file = file; + this._options = options; this.imports = []; - this._makeTitle = makeTitle; } _parseTitle(value: t.Node) { @@ -216,7 +251,7 @@ export class CsfFile { } throw new Error(dedent` - CSF: unexpected dynamic title ${formatLocation(node, this._fileName)} + CSF: unexpected dynamic title ${formatLocation(node, this._options.fileName)} More info: https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#string-literal-titles `); @@ -295,14 +330,34 @@ export class CsfFile { const self = this; traverse(this._ast, { ExportDefaultDeclaration: { - enter({ node, parent }) { - let metaNode: t.ObjectExpression | undefined; + enter(path) { + const { node, parent } = path; const isVariableReference = t.isIdentifier(node.declaration) && t.isProgram(parent); + + if ( + self._options.transformInlineMeta && + !isVariableReference && + t.isExpression(node.declaration) + ) { + const metaId = path.scope.generateUidIdentifier('meta'); + self._metaVariableName = metaId.name; + const nodes = [ + t.variableDeclaration('const', [t.variableDeclarator(metaId, node.declaration)]), + t.exportDefaultDeclaration(metaId), + ]; + + // Preserve sourcemaps location + nodes.forEach((_node: t.Node) => (_node.loc = path.node.loc)); + path.replaceWithMultiple(nodes); + } + + let metaNode: t.ObjectExpression | undefined; let decl; if (isVariableReference) { // const meta = { ... }; // export default meta; const variableName = (node.declaration as t.Identifier).name; + self._metaVariableName = variableName; const isVariableDeclarator = (declaration: t.VariableDeclarator) => t.isIdentifier(declaration.id) && declaration.id.name === variableName; @@ -339,7 +394,7 @@ export class CsfFile { throw new NoMetaError( 'default export must be an object', self._metaStatement, - self._fileName + self._options.fileName ); } }, @@ -429,11 +484,12 @@ export class CsfFile { node.specifiers.forEach((specifier) => { if (t.isExportSpecifier(specifier) && t.isIdentifier(specifier.exported)) { const { name: exportName } = specifier.exported; + const decl = t.isProgram(parent) + ? findVarInitialization(specifier.local.name, parent) + : specifier.local; + if (exportName === 'default') { let metaNode: t.ObjectExpression | undefined; - const decl = t.isProgram(parent) - ? findVarInitialization(specifier.local.name, parent) - : specifier.local; if (t.isObjectExpression(decl)) { // export default { ... }; @@ -451,6 +507,7 @@ export class CsfFile { } } else { self._storyAnnotations[exportName] = {}; + self._storyStatements[exportName] = decl; self._stories[exportName] = { id: 'FIXME', name: exportName, @@ -507,7 +564,7 @@ export class CsfFile { const { callee } = node; if (t.isIdentifier(callee) && callee.name === 'storiesOf') { throw new Error(dedent` - Unexpected \`storiesOf\` usage: ${formatLocation(node, self._fileName)}. + Unexpected \`storiesOf\` usage: ${formatLocation(node, self._options.fileName)}. SB8 does not support \`storiesOf\`. `); @@ -527,12 +584,12 @@ export class CsfFile { }); if (!self._meta) { - throw new NoMetaError('missing default export', self._ast, self._fileName); + throw new NoMetaError('missing default export', self._ast, self._options.fileName); } // default export can come at any point in the file, so we do this post processing last const entries = Object.entries(self._stories); - self._meta.title = this._makeTitle(self._meta?.title as string); + self._meta.title = this._options.makeTitle(self._meta?.title as string); if (self._metaAnnotations.play) { self._meta.tags = [...(self._meta.tags || []), 'play-fn']; } @@ -586,6 +643,7 @@ export class CsfFile { if (!isExportStory(key, self._meta as StaticMeta)) { delete self._storyExports[key]; delete self._storyAnnotations[key]; + delete self._storyStatements[key]; } }); @@ -616,7 +674,8 @@ export class CsfFile { } public get indexInputs(): IndexInput[] { - if (!this._fileName) { + const { fileName } = this._options; + if (!fileName) { throw new Error( dedent`Cannot automatically create index inputs with CsfFile.indexInputs because the CsfFile instance was created without a the fileName option. Either add the fileName option when creating the CsfFile instance, or create the index inputs manually.` @@ -628,7 +687,7 @@ export class CsfFile { const tags = [...(this._meta?.tags ?? []), ...(story.tags ?? [])]; return { type: 'story', - importPath: this._fileName, + importPath: fileName, rawComponentPath: this._rawComponentPath, exportName, name: story.name, @@ -642,9 +701,25 @@ export class CsfFile { } } +/** + * Using new babel.File is more powerful and give access to API such as buildCodeFrameError + */ +export const babelParseFile = ({ + code, + filename = '', + ast, +}: { + code: string; + filename?: string; + ast?: t.File; +}): BabelFile => { + return new BabelFileClass({ filename }, { code, ast: ast ?? babelParse(code) }); +}; + export const loadCsf = (code: string, options: CsfOptions) => { const ast = babelParse(code); - return new CsfFile(ast, options); + const file = babelParseFile({ code, filename: options.fileName, ast }); + return new CsfFile(ast, options, file); }; export const formatCsf = ( @@ -672,7 +747,7 @@ export const readCsf = async (fileName: string, options: CsfOptions) => { }; export const writeCsf = async (csf: CsfFile, fileName?: string) => { - const fname = fileName || csf._fileName; + const fname = fileName || csf._options.fileName; if (!fname) throw new Error('Please specify a fileName for writeCsf'); await writeFile(fileName as string, printCsf(csf).code); }; diff --git a/code/core/src/csf-tools/index.ts b/code/core/src/csf-tools/index.ts index 51c57c62aba1..6cc00f61f7c3 100644 --- a/code/core/src/csf-tools/index.ts +++ b/code/core/src/csf-tools/index.ts @@ -3,3 +3,4 @@ export * from './ConfigFile'; export * from './getStorySortParameter'; export * from './enrichCsf'; export * from './babelParse'; +export { vitestTransform } from './vitest-plugin/transformer'; diff --git a/code/core/src/csf-tools/vitest-plugin/transformer.test.ts b/code/core/src/csf-tools/vitest-plugin/transformer.test.ts new file mode 100644 index 000000000000..76ec0458ab9a --- /dev/null +++ b/code/core/src/csf-tools/vitest-plugin/transformer.test.ts @@ -0,0 +1,351 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getStoryTitle } from '@storybook/core/common'; + +import { type RawSourceMap, SourceMapConsumer } from 'source-map'; + +import { vitestTransform as originalTransform } from './transformer'; + +vi.mock('@storybook/core/common', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getStoryTitle: vi.fn(() => 'automatic/calculated/title'), + }; +}); + +expect.addSnapshotSerializer({ + serialize: (val: any) => (typeof val === 'string' ? val : val.toString()), + test: (val) => true, +}); + +const transform = async ({ + code = '', + fileName = 'src/components/Button.stories.js', + tagsFilter = { + include: ['test'], + exclude: [], + skip: [], + }, + configDir = '.storybook', + stories = [], +}) => { + const transformed = await originalTransform({ code, fileName, configDir, stories, tagsFilter }); + if (typeof transformed === 'string') { + return { code: transformed, map: null }; + } + + return transformed; +}; + +describe('transformer', () => { + describe('no-op', () => { + it('should return original code if the file is not a story file', async () => { + const code = `console.log('Not a story file');`; + const fileName = 'src/components/Button.js'; + + const result = await transform({ code, fileName }); + + expect(result.code).toMatchInlineSnapshot(`console.log('Not a story file');`); + }); + }); + + describe('default exports (meta)', () => { + it('should add title to inline default export if not present', async () => { + const code = ` + import { _test } from 'bla'; + export default { + component: Button, + }; + `; + + const result = await transform({ code }); + + expect(getStoryTitle).toHaveBeenCalled(); + + expect(result.code).toMatchInlineSnapshot(` + import { test as _test2 } from "vitest"; + import { composeStory as _composeStory } from "storybook/internal/preview-api"; + import { testStory as _testStory, isValidTest as _isValidTest } from "@storybook/experimental-addon-vitest/internal/test-utils"; + import { _test } from 'bla'; + const _meta = { + component: Button, + title: "automatic/calculated/title" + }; + export default _meta; + `); + }); + + it('should overwrite title to inline default export if already present', async () => { + const code = ` + export default { + title: 'Button', + component: Button, + }; + `; + + const result = await transform({ code }); + + expect(getStoryTitle).toHaveBeenCalled(); + + expect(result.code).toMatchInlineSnapshot(` + import { test as _test } from "vitest"; + import { composeStory as _composeStory } from "storybook/internal/preview-api"; + import { testStory as _testStory, isValidTest as _isValidTest } from "@storybook/experimental-addon-vitest/internal/test-utils"; + const _meta = { + title: "automatic/calculated/title", + component: Button + }; + export default _meta; + `); + }); + + it('should add title to const declared default export if not present', async () => { + const code = ` + const meta = { + component: Button, + }; + + export default meta; + `; + + const result = await transform({ code }); + + expect(getStoryTitle).toHaveBeenCalled(); + + expect(result.code).toMatchInlineSnapshot(` + import { test as _test } from "vitest"; + import { composeStory as _composeStory } from "storybook/internal/preview-api"; + import { testStory as _testStory, isValidTest as _isValidTest } from "@storybook/experimental-addon-vitest/internal/test-utils"; + const meta = { + component: Button, + title: "automatic/calculated/title" + }; + export default meta; + `); + }); + + it('should overwrite title to const declared default export if already present', async () => { + const code = ` + const meta = { + title: 'Button', + component: Button, + }; + + export default meta; + `; + + const result = await transform({ code }); + + expect(getStoryTitle).toHaveBeenCalled(); + + expect(result.code).toMatchInlineSnapshot(` + import { test as _test } from "vitest"; + import { composeStory as _composeStory } from "storybook/internal/preview-api"; + import { testStory as _testStory, isValidTest as _isValidTest } from "@storybook/experimental-addon-vitest/internal/test-utils"; + const meta = { + title: "automatic/calculated/title", + component: Button + }; + export default meta; + `); + }); + }); + + describe('named exports (stories)', () => { + it('should add test statement to inline exported stories', async () => { + const code = ` + export default { + component: Button, + } + export const Primary = { + args: { + label: 'Primary Button', + }, + }; + `; + + const result = await transform({ code }); + + expect(result.code).toMatchInlineSnapshot(` + import { test as _test } from "vitest"; + import { composeStory as _composeStory } from "storybook/internal/preview-api"; + import { testStory as _testStory, isValidTest as _isValidTest } from "@storybook/experimental-addon-vitest/internal/test-utils"; + const _meta = { + component: Button, + title: "automatic/calculated/title" + }; + export default _meta; + export const Primary = { + args: { + label: 'Primary Button' + } + }; + const _composedPrimary = _composeStory(Primary, _meta, undefined, undefined, "Primary"); + if (_isValidTest(_composedPrimary.tags, {"include":["test"],"exclude":[],"skip":[]})) { + _test("Primary", _testStory(_composedPrimary, {"include":["test"],"exclude":[],"skip":[]})); + } + `); + }); + + it('should add test statement to const declared exported stories', async () => { + const code = ` + export default {}; + const Primary = { + args: { + label: 'Primary Button', + }, + }; + + export { Primary }; + `; + + const result = await transform({ code }); + + expect(result.code).toMatchInlineSnapshot(` + import { test as _test } from "vitest"; + import { composeStory as _composeStory } from "storybook/internal/preview-api"; + import { testStory as _testStory, isValidTest as _isValidTest } from "@storybook/experimental-addon-vitest/internal/test-utils"; + const _meta = { + title: "automatic/calculated/title" + }; + export default _meta; + const Primary = { + args: { + label: 'Primary Button' + } + }; + export { Primary }; + const _composedPrimary = _composeStory(Primary, _meta, undefined, undefined, "Primary"); + if (_isValidTest(_composedPrimary.tags, {"include":["test"],"exclude":[],"skip":[]})) { + _test("Primary", _testStory(_composedPrimary, {"include":["test"],"exclude":[],"skip":[]})); + } + `); + }); + + it('should exclude exports via excludeStories', async () => { + const code = ` + export default { + title: 'Button', + component: Button, + excludeStories: ['nonStory'], + } + export const nonStory = 123 + `; + + const result = await transform({ code }); + + expect(result.code).toMatchInlineSnapshot(` + import { test as _test } from "vitest"; + import { composeStory as _composeStory } from "storybook/internal/preview-api"; + import { testStory as _testStory, isValidTest as _isValidTest } from "@storybook/experimental-addon-vitest/internal/test-utils"; + const _meta = { + title: "automatic/calculated/title", + component: Button, + excludeStories: ['nonStory'] + }; + export default _meta; + export const nonStory = 123; + `); + }); + }); + + describe('source map calculation', () => { + it('should remap the location of an inline named export to its relative testStory function', async () => { + const originalCode = ` + const meta = { + title: 'Button', + component: Button, + } + export default meta; + export const Primary = {}; + `; + + const { code: transformedCode, map } = await transform({ + code: originalCode, + }); + + expect(transformedCode).toMatchInlineSnapshot(` + import { test as _test } from "vitest"; + import { composeStory as _composeStory } from "storybook/internal/preview-api"; + import { testStory as _testStory, isValidTest as _isValidTest } from "@storybook/experimental-addon-vitest/internal/test-utils"; + const meta = { + title: "automatic/calculated/title", + component: Button + }; + export default meta; + export const Primary = {}; + const _composedPrimary = _composeStory(Primary, meta, undefined, undefined, "Primary"); + if (_isValidTest(_composedPrimary.tags, {"include":["test"],"exclude":[],"skip":[]})) { + _test("Primary", _testStory(_composedPrimary, {"include":["test"],"exclude":[],"skip":[]})); + } + `); + + const consumer = await new SourceMapConsumer(map as unknown as RawSourceMap); + + // Locate `__test("Primary"...` in the transformed code + const testPrimaryLine = + transformedCode.split('\n').findIndex((line) => line.includes('_test("Primary"')) + 1; + const testPrimaryColumn = transformedCode + .split('\n') + [testPrimaryLine - 1].indexOf('_test("Primary"'); + + // Get the original position from the source map for `__test("Primary"...` + const originalPosition = consumer.originalPositionFor({ + line: testPrimaryLine, + column: testPrimaryColumn, + }); + + // Locate `export const Primary` in the original code + const originalPrimaryLine = + originalCode.split('\n').findIndex((line) => line.includes('export const Primary')) + 1; + const originalPrimaryColumn = originalCode + .split('\n') + [originalPrimaryLine - 1].indexOf('export const Primary'); + + // The original locations of the transformed code should match with the ones of the original code + expect(originalPosition.line, 'original line location').toBe(originalPrimaryLine); + expect(originalPosition.column, 'original column location').toBe(originalPrimaryColumn); + }); + }); + + describe('error handling', () => { + const warnSpy = vi.spyOn(console, 'warn'); + beforeEach(() => { + vi.mocked(getStoryTitle).mockRestore(); + warnSpy.mockReset(); + }); + + it('should warn when autotitle is not successful', async () => { + const code = ` + export default {} + export const Primary = {}; + `; + + vi.mocked(getStoryTitle).mockImplementation(() => undefined); + + warnSpy.mockImplementation(() => {}); + + await transform({ code }); + expect(warnSpy.mock.calls[0]).toMatchInlineSnapshot(` + [Storybook]: Could not calculate story title for "src/components/Button.stories.js". + Please make sure that this file matches the globs included in the "stories" field in your Storybook configuration at ".storybook". + `); + }); + + it('should warn when on unsupported story formats', async () => { + const code = ` + export default {} + export { Primary } from './Button.stories'; + `; + + warnSpy.mockImplementation(() => {}); + + await transform({ code }); + expect(warnSpy.mock.calls[0]).toMatchInlineSnapshot(` + [Storybook]: Could not transform "Primary" story into test at "src/components/Button.stories.js". + Please make sure to define stories in the same file and not re-export stories coming from other files". + `); + }); + }); +}); diff --git a/code/core/src/csf-tools/vitest-plugin/transformer.ts b/code/core/src/csf-tools/vitest-plugin/transformer.ts new file mode 100644 index 000000000000..aafe709a2399 --- /dev/null +++ b/code/core/src/csf-tools/vitest-plugin/transformer.ts @@ -0,0 +1,170 @@ +/* eslint-disable local-rules/no-uncategorized-errors */ + +/* eslint-disable no-underscore-dangle */ +import { getStoryTitle } from '@storybook/core/common'; +import type { StoriesEntry } from '@storybook/core/types'; + +import * as t from '@babel/types'; +import { dedent } from 'ts-dedent'; + +import { formatCsf, loadCsf } from '../CsfFile'; + +const logger = console; + +export async function vitestTransform({ + code, + fileName, + configDir, + stories, + tagsFilter, +}: { + code: string; + fileName: string; + configDir: string; + tagsFilter: { + include: string[]; + exclude: string[]; + skip: string[]; + }; + stories: StoriesEntry[]; +}) { + const isStoryFile = /\.stor(y|ies)\./.test(fileName); + if (!isStoryFile) { + return code; + } + + const parsed = loadCsf(code, { + fileName, + transformInlineMeta: true, + makeTitle: (title) => { + const result = + getStoryTitle({ + storyFilePath: fileName, + configDir, + stories, + userTitle: title, + }) || 'unknown'; + + if (result === 'unknown') { + logger.warn( + dedent` + [Storybook]: Could not calculate story title for "${fileName}". + Please make sure that this file matches the globs included in the "stories" field in your Storybook configuration at "${configDir}". + ` + ); + } + return result; + }, + }).parse(); + + const ast = parsed._ast; + + const metaExportName = parsed._metaVariableName!; + + const metaNode = parsed._metaNode as t.ObjectExpression; + + const metaTitleProperty = metaNode.properties.find( + (prop) => t.isObjectProperty(prop) && t.isIdentifier(prop.key) && prop.key.name === 'title' + ); + + const metaTitle = t.stringLiteral(parsed._meta?.title || 'unknown'); + if (!metaTitleProperty) { + metaNode.properties.push(t.objectProperty(t.identifier('title'), metaTitle)); + } else if (t.isObjectProperty(metaTitleProperty)) { + // If the title is present in meta, overwrite it because autotitle can still affect existing titles + metaTitleProperty.value = metaTitle; + } + + if (!metaNode || !parsed._meta) { + throw new Error( + 'The Storybook vitest plugin could not detect the meta (default export) object in the story file. \n\nPlease make sure you have a default export with the meta object. If you are using a different export format that is not supported, please file an issue with details about your use case.' + ); + } + + const vitestTestId = parsed._file.path.scope.generateUidIdentifier('test'); + const composeStoryId = parsed._file.path.scope.generateUidIdentifier('composeStory'); + const testStoryId = parsed._file.path.scope.generateUidIdentifier('testStory'); + const isValidTestId = parsed._file.path.scope.generateUidIdentifier('isValidTest'); + + const tagsFilterId = t.identifier(JSON.stringify(tagsFilter)); + + const getTestStatementForStory = ({ exportName, node }: { exportName: string; node: t.Node }) => { + const composedStoryId = parsed._file.path.scope.generateUidIdentifier(`composed${exportName}`); + + const composeStoryCall = t.variableDeclaration('const', [ + t.variableDeclarator( + composedStoryId, + t.callExpression(composeStoryId, [ + t.identifier(exportName), + t.identifier(metaExportName), + t.identifier('undefined'), + t.identifier('undefined'), + t.stringLiteral(exportName), + ]) + ), + ]); + + // Preserve sourcemaps location + composeStoryCall.loc = node.loc; + + const isValidTestCall = t.ifStatement( + t.callExpression(isValidTestId, [ + t.memberExpression(composedStoryId, t.identifier('tags')), + tagsFilterId, + ]), + t.blockStatement([ + t.expressionStatement( + t.callExpression(vitestTestId, [ + t.stringLiteral(exportName), + t.callExpression(testStoryId, [composedStoryId, tagsFilterId]), + ]) + ), + ]) + ); + // Preserve sourcemaps location + isValidTestCall.loc = node.loc; + + return [composeStoryCall, isValidTestCall]; + }; + + Object.entries(parsed._storyStatements).forEach(([exportName, node]) => { + if (node === null) { + logger.warn( + dedent` + [Storybook]: Could not transform "${exportName}" story into test at "${fileName}". + Please make sure to define stories in the same file and not re-export stories coming from other files". + ` + ); + return; + } + + ast.program.body.push( + ...getTestStatementForStory({ + exportName, + node, + }) + ); + }); + + const imports = [ + t.importDeclaration( + [t.importSpecifier(vitestTestId, t.identifier('test'))], + t.stringLiteral('vitest') + ), + t.importDeclaration( + [t.importSpecifier(composeStoryId, t.identifier('composeStory'))], + t.stringLiteral('storybook/internal/preview-api') + ), + t.importDeclaration( + [ + t.importSpecifier(testStoryId, t.identifier('testStory')), + t.importSpecifier(isValidTestId, t.identifier('isValidTest')), + ], + t.stringLiteral('@storybook/experimental-addon-vitest/internal/test-utils') + ), + ]; + + ast.program.body.unshift(...imports); + + return formatCsf(parsed, { sourceMaps: true, sourceFileName: fileName }, code); +} diff --git a/code/core/src/manager-api/modules/stories.ts b/code/core/src/manager-api/modules/stories.ts index 20bfdee92db0..3f2d2a31e97e 100644 --- a/code/core/src/manager-api/modules/stories.ts +++ b/code/core/src/manager-api/modules/stories.ts @@ -34,6 +34,7 @@ import { SELECT_STORY, SET_CONFIG, SET_CURRENT_STORY, + SET_FILTER, SET_INDEX, SET_STORIES, STORY_ARGS_UPDATED, @@ -617,11 +618,14 @@ export const init: ModuleFn = ({ const update = typeof input === 'function' ? input(status) : input; - if (Object.keys(update).length === 0) { + if (!id || Object.keys(update).length === 0) { return; } Object.entries(update).forEach(([storyId, value]) => { + if (!storyId || typeof value !== 'object') { + return; + } newStatus[storyId] = { ...(newStatus[storyId] || {}) }; if (value === null) { delete newStatus[storyId][id]; @@ -661,6 +665,8 @@ export const init: ModuleFn = ({ Object.entries(refs).forEach(([refId, { internal_index, ...ref }]) => { fullAPI.setRef(refId, { ...ref, storyIndex: internal_index }, true); }); + + provider.channel?.emit(SET_FILTER, { id }); }, }; diff --git a/code/core/src/manager/components/sidebar/FilterToggle.stories.ts b/code/core/src/manager/components/sidebar/FilterToggle.stories.ts new file mode 100644 index 000000000000..6710af80c37c --- /dev/null +++ b/code/core/src/manager/components/sidebar/FilterToggle.stories.ts @@ -0,0 +1,41 @@ +import { fn } from '@storybook/test'; + +import { FilterToggle } from './FilterToggle'; + +export default { + component: FilterToggle, + args: { + active: false, + onClick: fn(), + }, +}; + +export const Errors = { + args: { + count: 2, + label: 'Error', + status: 'critical', + }, +}; + +export const ErrorsActive = { + args: { + ...Errors.args, + active: true, + }, +}; + +export const Warning = { + args: { + count: 12, + label: 'Warning', + status: 'warning', + }, +}; + +export const WarningActive = { + args: { + ...Warning.args, + active: true, + }, +}; diff --git a/code/core/src/manager/components/sidebar/FilterToggle.tsx b/code/core/src/manager/components/sidebar/FilterToggle.tsx new file mode 100644 index 000000000000..30b5db37130e --- /dev/null +++ b/code/core/src/manager/components/sidebar/FilterToggle.tsx @@ -0,0 +1,60 @@ +import React, { type ComponentProps } from 'react'; + +import { Badge as BaseBadge, IconButton } from '@storybook/components'; +import { css, styled } from '@storybook/theming'; + +const Badge = styled(BaseBadge)(({ theme }) => ({ + padding: '4px 8px', + fontSize: theme.typography.size.s1, +})); + +const Button = styled(IconButton)( + ({ theme }) => ({ + fontSize: theme.typography.size.s2, + '&:hover [data-badge][data-status=warning], [data-badge=true][data-status=warning]': { + background: '#E3F3FF', + borderColor: 'rgba(2, 113, 182, 0.1)', + color: '#0271B6', + }, + '&:hover [data-badge][data-status=critical], [data-badge=true][data-status=critical]': { + background: theme.background.negative, + boxShadow: `inset 0 0 0 1px rgba(182, 2, 2, 0.1)`, + color: theme.color.negativeText, + }, + }), + ({ active, theme }) => + !active && + css({ + '&:hover': { + color: theme.base === 'light' ? theme.color.defaultText : theme.color.light, + }, + }) +); + +const Label = styled.span(({ theme }) => ({ + color: theme.base === 'light' ? theme.color.defaultText : theme.color.light, +})); + +interface FilterToggleProps { + active: boolean; + count: number; + label: string; + status: ComponentProps['status']; +} + +export const FilterToggle = ({ + active, + count, + label, + status, + ...props +}: FilterToggleProps & Omit, 'status'>) => { + return ( + + ); +}; diff --git a/code/core/src/manager/components/sidebar/IconSymbols.tsx b/code/core/src/manager/components/sidebar/IconSymbols.tsx index 20101343e7b2..9b27b8c3e9d0 100644 --- a/code/core/src/manager/components/sidebar/IconSymbols.tsx +++ b/code/core/src/manager/components/sidebar/IconSymbols.tsx @@ -19,6 +19,10 @@ const GROUP_ID = 'icon--group'; const COMPONENT_ID = 'icon--component'; const DOCUMENT_ID = 'icon--document'; const STORY_ID = 'icon--story'; +const SUCCESS_ID = 'icon--success'; +const ERROR_ID = 'icon--error'; +const WARNING_ID = 'icon--warning'; +const DOT_ID = 'icon--dot'; export const IconSymbols: FC = () => { return ( @@ -63,14 +67,47 @@ export const IconSymbols: FC = () => { fill="currentColor" /> + + + + + + + + + + + + ); }; -export const UseSymbol: FC<{ type: 'group' | 'component' | 'document' | 'story' }> = ({ type }) => { +export const UseSymbol: FC<{ + type: 'group' | 'component' | 'document' | 'story' | 'success' | 'error' | 'warning' | 'dot'; +}> = ({ type }) => { if (type === 'group') return ; if (type === 'component') return ; if (type === 'document') return ; if (type === 'story') return ; + if (type === 'success') return ; + if (type === 'error') return ; + if (type === 'warning') return ; + if (type === 'dot') return ; return null; }; diff --git a/code/core/src/manager/components/sidebar/SearchResults.tsx b/code/core/src/manager/components/sidebar/SearchResults.tsx index 4c7793ef9b8d..ec24be45d300 100644 --- a/code/core/src/manager/components/sidebar/SearchResults.tsx +++ b/code/core/src/manager/components/sidebar/SearchResults.tsx @@ -15,6 +15,7 @@ import { transparentize } from 'polished'; import { matchesKeyCode, matchesModifiers } from '../../keybinding'; import { statusMapping } from '../../utils/status'; import { UseSymbol } from './IconSymbols'; +import { StatusLabel } from './StatusButton'; import { TypeIcon } from './TreeNode'; import type { DownshiftItem, Match, SearchResult } from './types'; import { isExpandType } from './types'; @@ -33,6 +34,7 @@ const ResultRow = styled.li<{ isHighlighted: boolean }>(({ theme, isHighlighted cursor: 'pointer', display: 'flex', alignItems: 'start', + justifyContent: 'space-between', textAlign: 'left', color: 'inherit', fontSize: `${theme.typography.size.s2}px`, @@ -56,6 +58,7 @@ const IconWrapper = styled.div({ }); const ResultRowContent = styled.div({ + flex: 1, display: 'flex', flexDirection: 'column', }); @@ -183,7 +186,7 @@ const Result: FC< const nameMatch = matches.find((match: Match) => match.key === 'name'); const pathMatches = matches.filter((match: Match) => match.key === 'path'); - const [i] = item.status ? statusMapping[item.status] : []; + const [icon] = item.status ? statusMapping[item.status] : []; return ( @@ -218,7 +221,7 @@ const Result: FC< ))} - {item.status ? i : null} + {item.status ? {icon} : null} ); }); diff --git a/code/core/src/manager/components/sidebar/Sidebar.stories.tsx b/code/core/src/manager/components/sidebar/Sidebar.stories.tsx index 230e68ee203f..b98884042fb5 100644 --- a/code/core/src/manager/components/sidebar/Sidebar.stories.tsx +++ b/code/core/src/manager/components/sidebar/Sidebar.stories.tsx @@ -1,13 +1,11 @@ import React from 'react'; -import { Button, IconButton } from '@storybook/core/components'; -import type { Addon_SidebarTopType } from '@storybook/core/types'; -import { FaceHappyIcon } from '@storybook/icons'; +import type { API_StatusState, Addon_SidebarTopType } from '@storybook/core/types'; import type { Meta, StoryObj } from '@storybook/react'; import { expect, fn, userEvent, within } from '@storybook/test'; import type { IndexHash, State } from '@storybook/core/manager-api'; -import { ManagerContext, types } from '@storybook/core/manager-api'; +import { ManagerContext } from '@storybook/core/manager-api'; import { LayoutProvider } from '../layout/LayoutProvider'; import { standardData as standardHeaderData } from './Heading.stories'; @@ -28,6 +26,26 @@ const storyId = 'root-1-child-a2--grandchild-a1-1'; export const simpleData = { menu, index, storyId }; export const loadingData = { menu }; +const managerContext: any = { + state: { + docsOptions: { + defaultName: 'Docs', + autodocs: 'tag', + docsMode: false, + }, + }, + api: { + emit: fn().mockName('api::emit'), + on: fn().mockName('api::on'), + off: fn().mockName('api::off'), + getShortcutKeys: fn(() => ({ search: ['control', 'shift', 's'] })).mockName( + 'api::getShortcutKeys' + ), + selectStory: fn().mockName('api::selectStory'), + experimental_setFilter: fn().mockName('api::experimental_setFilter'), + }, +}; + const meta = { component: Sidebar, title: 'Sidebar/Sidebar', @@ -46,28 +64,7 @@ const meta = { }, decorators: [ (storyFn) => ( - ({ search: ['control', 'shift', 's'] })).mockName( - 'api::getShortcutKeys' - ), - selectStory: fn().mockName('api::selectStory'), - }, - } as any - } - > + {storyFn()} @@ -229,41 +226,29 @@ export const Searching: Story = { }; export const Bottom: Story = { - args: { - bottom: [ - { - id: '1', - type: types.experimental_SIDEBAR_BOTTOM, - render: () => ( - - ), - }, - { - id: '2', - type: types.experimental_SIDEBAR_BOTTOM, - render: () => ( - - ), - }, - { - id: '3', - type: types.experimental_SIDEBAR_BOTTOM, - render: () => ( - - {' '} - - - ), - }, - ], - }, + decorators: [ + (storyFn) => ( + + {storyFn()} + + ), + ], }; /** diff --git a/code/core/src/manager/components/sidebar/Sidebar.tsx b/code/core/src/manager/components/sidebar/Sidebar.tsx index 5cc306d796fb..e937012c43e6 100644 --- a/code/core/src/manager/components/sidebar/Sidebar.tsx +++ b/code/core/src/manager/components/sidebar/Sidebar.tsx @@ -2,11 +2,7 @@ import React, { useMemo } from 'react'; import { ScrollArea, Spaced } from '@storybook/core/components'; import { styled } from '@storybook/core/theming'; -import type { - API_LoadedRefData, - Addon_SidebarBottomType, - Addon_SidebarTopType, -} from '@storybook/core/types'; +import type { API_LoadedRefData, Addon_SidebarTopType } from '@storybook/core/types'; import type { State } from '@storybook/core/manager-api'; @@ -16,6 +12,7 @@ import type { HeadingProps } from './Heading'; import { Heading } from './Heading'; import { Search } from './Search'; import { SearchResults } from './SearchResults'; +import { SidebarBottom } from './SidebarBottom'; import type { CombinedDataset, Selection } from './types'; import { useLastViewed } from './useLastViewed'; @@ -107,7 +104,6 @@ export interface SidebarProps extends API_LoadedRefData { status: State['status']; menu: any[]; extra: Addon_SidebarTopType[]; - bottom?: Addon_SidebarBottomType[]; storyId?: string; refId?: string; menuHighlighted?: boolean; @@ -126,7 +122,6 @@ export const Sidebar = React.memo(function Sidebar({ previewInitialized, menu, extra, - bottom = [], menuHighlighted = false, enableShortcuts = true, refs = {}, @@ -192,9 +187,7 @@ export const Sidebar = React.memo(function Sidebar({ {isLoading ? null : ( - {bottom.map(({ id, render: Render }) => ( - - ))} + )} diff --git a/code/core/src/manager/components/sidebar/SidebarBottom.stories.tsx b/code/core/src/manager/components/sidebar/SidebarBottom.stories.tsx new file mode 100644 index 000000000000..498750fd82e0 --- /dev/null +++ b/code/core/src/manager/components/sidebar/SidebarBottom.stories.tsx @@ -0,0 +1,42 @@ +import { fn } from '@storybook/test'; + +import { SidebarBottomBase } from './SidebarBottom'; + +export default { + component: SidebarBottomBase, + args: { + api: { + experimental_setFilter: fn(), + emit: fn(), + }, + }, +}; + +export const Errors = { + args: { + status: { + one: { 'sidebar-bottom-filter': { status: 'error' } }, + two: { 'sidebar-bottom-filter': { status: 'error' } }, + }, + }, +}; + +export const Warnings = { + args: { + status: { + one: { 'sidebar-bottom-filter': { status: 'warn' } }, + two: { 'sidebar-bottom-filter': { status: 'warn' } }, + }, + }, +}; + +export const Both = { + args: { + status: { + one: { 'sidebar-bottom-filter': { status: 'warn' } }, + two: { 'sidebar-bottom-filter': { status: 'warn' } }, + three: { 'sidebar-bottom-filter': { status: 'error' } }, + four: { 'sidebar-bottom-filter': { status: 'error' } }, + }, + }, +}; diff --git a/code/core/src/manager/components/sidebar/SidebarBottom.tsx b/code/core/src/manager/components/sidebar/SidebarBottom.tsx new file mode 100644 index 000000000000..dd3ed6157605 --- /dev/null +++ b/code/core/src/manager/components/sidebar/SidebarBottom.tsx @@ -0,0 +1,93 @@ +import React, { useCallback, useEffect } from 'react'; + +import { styled } from '@storybook/core/theming'; +import type { API_FilterFunction } from '@storybook/types'; + +import { + type API, + type State, + useStorybookApi, + useStorybookState, +} from '@storybook/core/manager-api'; + +import { FilterToggle } from './FilterToggle'; + +const filterNone: API_FilterFunction = () => true; +const filterWarn: API_FilterFunction = ({ status = {} }) => + Object.values(status).some((value) => value?.status === 'warn'); +const filterError: API_FilterFunction = ({ status = {} }) => + Object.values(status).some((value) => value?.status === 'error'); +const filterBoth: API_FilterFunction = ({ status = {} }) => + Object.values(status).some((value) => value?.status === 'warn' || value?.status === 'error'); + +const getFilter = (showWarnings = false, showErrors = false) => { + if (showWarnings && showErrors) return filterBoth; + if (showWarnings) return filterWarn; + if (showErrors) return filterError; + return filterNone; +}; + +const Wrapper = styled.div({ + display: 'flex', + gap: 5, +}); + +interface SidebarBottomProps { + api: API; + status: State['status']; +} + +export const SidebarBottomBase = ({ api, status = {} }: SidebarBottomProps) => { + const [showWarnings, setShowWarnings] = React.useState(false); + const [showErrors, setShowErrors] = React.useState(false); + + const warnings = Object.values(status).filter((statusByAddonId) => + Object.values(statusByAddonId).some((value) => value?.status === 'warn') + ); + const errors = Object.values(status).filter((statusByAddonId) => + Object.values(statusByAddonId).some((value) => value?.status === 'error') + ); + const hasWarnings = warnings.length > 0; + const hasErrors = errors.length > 0; + + const toggleWarnings = useCallback(() => setShowWarnings((shown) => !shown), []); + const toggleErrors = useCallback(() => setShowErrors((shown) => !shown), []); + + useEffect(() => { + const filter = getFilter(hasWarnings && showWarnings, hasErrors && showErrors); + api.experimental_setFilter('sidebar-bottom-filter', filter); + }, [api, hasWarnings, hasErrors, showWarnings, showErrors]); + + if (!hasWarnings && !hasErrors) return null; + + return ( + + {hasErrors && ( + + )} + {hasWarnings && ( + + )} + + ); +}; + +export const SidebarBottom = () => { + const api = useStorybookApi(); + const { status } = useStorybookState(); + return ; +}; diff --git a/code/core/src/manager/components/sidebar/StatusButton.tsx b/code/core/src/manager/components/sidebar/StatusButton.tsx new file mode 100644 index 000000000000..9d1b49998df7 --- /dev/null +++ b/code/core/src/manager/components/sidebar/StatusButton.tsx @@ -0,0 +1,64 @@ +import { IconButton } from '@storybook/core/components'; +import { styled } from '@storybook/core/theming'; +import type { API_StatusValue } from '@storybook/types'; + +import type { Theme } from '@emotion/react'; +import { transparentize } from 'polished'; + +const withStatusColor = ({ theme, status }: { theme: Theme; status: API_StatusValue }) => { + const defaultColor = + theme.base === 'light' + ? transparentize(0.3, theme.color.defaultText) + : transparentize(0.6, theme.color.defaultText); + + return { + color: { + pending: defaultColor, + success: theme.color.positive, + error: theme.color.negative, + warn: theme.color.warning, + unknown: defaultColor, + }[status], + }; +}; + +export const StatusLabel = styled.div<{ status: API_StatusValue }>(withStatusColor, { + margin: 3, +}); + +export const StatusButton = styled(IconButton)<{ + height?: number; + width?: number; + status: API_StatusValue; + selectedItem?: boolean; +}>( + withStatusColor, + ({ theme, height, width }) => ({ + transition: 'none', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: width || 28, + height: height || 28, + + '&:hover': { + color: theme.color.secondary, + }, + + '&:focus': { + color: theme.color.secondary, + borderColor: theme.color.secondary, + + '&:not(:focus-visible)': { + borderColor: 'transparent', + }, + }, + }), + ({ theme, selectedItem }) => + selectedItem && { + '&:hover': { + boxShadow: `inset 0 0 0 2px ${theme.color.secondary}`, + background: 'rgba(255, 255, 255, 0.2)', + }, + } +); diff --git a/code/core/src/manager/components/sidebar/StatusContext.tsx b/code/core/src/manager/components/sidebar/StatusContext.tsx new file mode 100644 index 000000000000..11c66d6f02b8 --- /dev/null +++ b/code/core/src/manager/components/sidebar/StatusContext.tsx @@ -0,0 +1,40 @@ +import { createContext, useContext } from 'react'; + +import type { API_StatusObject, API_StatusState, API_StatusValue, StoryId } from '@storybook/types'; + +import type { ComponentEntry, GroupEntry, StoriesHash } from '../../../manager-api'; +import { getDescendantIds } from '../../utils/tree'; + +export const StatusContext = createContext<{ + data?: StoriesHash; + status?: API_StatusState; + groupStatus?: Record; +}>({}); + +export const useStatusSummary = (item: GroupEntry | ComponentEntry) => { + const { data, status, groupStatus } = useContext(StatusContext); + const summary: { + counts: Record; + statuses: Record>; + } = { + counts: { pending: 0, success: 0, error: 0, warn: 0, unknown: 0 }, + statuses: { pending: {}, success: {}, error: {}, warn: {}, unknown: {} }, + }; + + if ( + data && + status && + groupStatus && + ['pending', 'warn', 'error'].includes(groupStatus[item.id]) + ) { + for (const storyId of getDescendantIds(data, item.id, false)) { + for (const value of Object.values(status[storyId] || {})) { + summary.counts[value.status]++; + summary.statuses[value.status][storyId] = summary.statuses[value.status][storyId] || []; + summary.statuses[value.status][storyId].push(value); + } + } + } + + return summary; +}; diff --git a/code/core/src/manager/components/sidebar/Tree.tsx b/code/core/src/manager/components/sidebar/Tree.tsx index 41699290cacc..33196106e61f 100644 --- a/code/core/src/manager/components/sidebar/Tree.tsx +++ b/code/core/src/manager/components/sidebar/Tree.tsx @@ -1,12 +1,19 @@ -import type { MutableRefObject } from 'react'; +import type { ComponentProps, MutableRefObject } from 'react'; import React, { useCallback, useMemo, useRef } from 'react'; import { Button, IconButton, TooltipLinkList, WithTooltip } from '@storybook/core/components'; -import { styled } from '@storybook/core/theming'; -import { CollapseIcon as CollapseIconSvg, ExpandAltIcon } from '@storybook/icons'; +import { styled, useTheme } from '@storybook/core/theming'; +import { + CollapseIcon as CollapseIconSvg, + ExpandAltIcon, + StatusFailIcon, + StatusPassIcon, + StatusWarnIcon, + SyncIcon, +} from '@storybook/icons'; +import type { API_StatusValue, StoryId } from '@storybook/types'; import { PRELOAD_ENTRIES } from '@storybook/core/core-events'; -import { useStorybookApi } from '@storybook/core/manager-api'; import type { API, ComponentEntry, @@ -15,6 +22,7 @@ import type { StoriesHash, StoryEntry, } from '@storybook/core/manager-api'; +import { useStorybookApi } from '@storybook/core/manager-api'; import { transparentize } from 'polished'; @@ -27,7 +35,9 @@ import { isStoryHoistable, } from '../../utils/tree'; import { useLayout } from '../layout/LayoutProvider'; -import { IconSymbols } from './IconSymbols'; +import { IconSymbols, UseSymbol } from './IconSymbols'; +import { StatusButton } from './StatusButton'; +import { StatusContext, useStatusSummary } from './StatusContext'; import { ComponentNode, DocumentNode, GroupNode, RootNode, StoryNode } from './TreeNode'; import { CollapseIcon } from './components/CollapseIcon'; import type { Highlight, Item } from './types'; @@ -39,49 +49,6 @@ const Container = styled.div<{ hasOrphans: boolean }>((props) => ({ marginBottom: 20, })); -export const Action = styled.button<{ height?: number; width?: number }>( - ({ theme, height, width }) => ({ - display: 'inline-flex', - alignItems: 'center', - justifyContent: 'center', - width: width || 20, - height: height || 20, - boxSizing: 'border-box', - margin: 0, - marginLeft: 'auto', - padding: 0, - outline: 0, - lineHeight: 'normal', - background: 'none', - border: `1px solid transparent`, - borderRadius: '100%', - cursor: 'pointer', - transition: 'all 150ms ease-out', - color: - theme.base === 'light' - ? transparentize(0.3, theme.color.defaultText) - : transparentize(0.6, theme.color.defaultText), - - '&:hover': { - color: theme.color.secondary, - }, - - '&:focus': { - color: theme.color.secondary, - borderColor: theme.color.secondary, - - '&:not(:focus-visible)': { - borderColor: 'transparent', - }, - }, - - svg: { - width: 10, - height: 10, - }, - }) -); - const CollapseButton = styled.button(({ theme }) => ({ all: 'unset', display: 'flex', @@ -104,15 +71,14 @@ export const LeafNodeStyleWrapper = styled.div(({ theme }) => ({ display: 'flex', justifyContent: 'space-between', alignItems: 'center', - paddingRight: 20, color: theme.color.defaultText, background: 'transparent', minHeight: 28, borderRadius: 4, '&:hover, &:focus': { - outline: 'none', background: transparentize(0.93, theme.color.secondary), + outline: 'none', }, '&[data-selected="true"]': { @@ -120,7 +86,7 @@ export const LeafNodeStyleWrapper = styled.div(({ theme }) => ({ background: theme.color.secondary, fontWeight: theme.typography.weight.bold, - '&:hover, &:focus': { + '&&:hover, &&:focus': { background: theme.color.secondary, }, svg: { color: theme.color.lightest }, @@ -165,19 +131,20 @@ interface NodeProps { setFullyExpanded?: () => void; onSelectStoryId: (itemId: string) => void; status: State['status'][keyof State['status']]; + groupStatus: Record; api: API; } const Node = React.memo(function Node({ item, status, + groupStatus, refId, docsMode, isOrphan, isDisplayed, isSelected, isFullyExpanded, - color, setFullyExpanded, isExpanded, setExpanded, @@ -185,6 +152,7 @@ const Node = React.memo(function Node({ api, }) { const { isDesktop, isMobile, setMobileMenuOpen } = useLayout(); + const theme = useTheme(); if (!isDisplayed) { return null; @@ -197,20 +165,22 @@ const Node = React.memo(function Node({ const statusValue = getHighestStatus(Object.values(status || {}).map((s) => s.status)); const [icon, textColor] = statusMapping[statusValue]; + const statusOrder: API_StatusValue[] = ['success', 'error', 'warn', 'pending', 'unknown']; + return ( (function Node({ )} {icon ? ( event.stopPropagation()} + placement="bottom" tooltip={() => ( ({ - id: k, - title: v.title, - description: v.description, - right: statusMapping[v.status][0], - }))} + links={Object.entries(status || {}) + .sort( + (a, b) => statusOrder.indexOf(a[1].status) - statusOrder.indexOf(b[1].status) + ) + .map(([addonId, value]) => ({ + id: addonId, + title: value.title, + description: value.description, + icon: { + success: , + error: , + warn: , + pending: , + unknown: null, + }[value.status], + onClick: () => { + onSelectStoryId(item.id); + value.onClick?.(); + }, + }))} /> )} - closeOnOutsideClick > - + {icon} - + ) : null} @@ -296,41 +280,96 @@ const Node = React.memo(function Node({ } if (item.type === 'component' || item.type === 'group') { + const { counts, statuses } = useStatusSummary(item); + + const itemStatus = groupStatus?.[item.id]; + const color = itemStatus ? statusMapping[itemStatus][1] : null; const BranchNode = item.type === 'component' ? ComponentNode : GroupNode; + + const createLinks: (onHide: () => void) => ComponentProps['links'] = ( + onHide + ) => { + const links = []; + if (counts.error) { + links.push({ + id: 'errors', + icon: , + title: `${counts.error} ${counts.error === 1 ? 'story' : 'stories'} with errors`, + onClick: () => { + const [firstStoryId, [firstError]] = Object.entries(statuses.error)[0]; + onSelectStoryId(firstStoryId); + firstError.onClick?.(); + onHide(); + }, + }); + } + if (counts.warn) { + links.push({ + id: 'warnings', + icon: , + title: `${counts.warn} ${counts.warn === 1 ? 'story' : 'stories'} with warnings`, + onClick: () => { + const [firstStoryId, [firstWarning]] = Object.entries(statuses.warn)[0]; + onSelectStoryId(firstStoryId); + firstWarning.onClick?.(); + onHide(); + }, + }); + } + return links; + }; + return ( - 0} - isExpanded={isExpanded} - onClick={(event) => { - event.preventDefault(); - setExpanded({ ids: [item.id], value: !isExpanded }); - if (item.type === 'component' && !isExpanded && isDesktop) onSelectStoryId(item.id); - }} - onMouseEnter={() => { - if (item.type === 'component') { - api.emit(PRELOAD_ENTRIES, { - ids: [item.children[0]], - options: { target: refId }, - }); - } - }} > - {(item.renderLabel as (i: typeof item, api: API) => React.ReactNode)?.(item, api) || - item.name} - + 0} + isExpanded={isExpanded} + onClick={(event) => { + event.preventDefault(); + setExpanded({ ids: [item.id], value: !isExpanded }); + if (item.type === 'component' && !isExpanded && isDesktop) onSelectStoryId(item.id); + }} + onMouseEnter={() => { + if (item.type === 'component') { + api.emit(PRELOAD_ENTRIES, { + ids: [item.children[0]], + options: { target: refId }, + }); + } + }} + > + {(item.renderLabel as (i: typeof item, api: API) => React.ReactNode)?.(item, api) || + item.name} + + {['error', 'warn'].includes(itemStatus) && ( + event.stopPropagation()} + placement="bottom" + tooltip={({ onHide }) => } + > + + + + + + + )} + ); } @@ -514,7 +553,6 @@ export const Tree = React.memo<{ } const isDisplayed = !item.parent || ancestry[itemId].every((a: string) => expanded[a]); - const color = groupStatus[itemId] ? statusMapping[groupStatus[itemId]][1] : null; return ( itemId === oid || itemId.startsWith(`${oid}-`))} isDisplayed={isDisplayed} @@ -554,9 +590,11 @@ export const Tree = React.memo<{ status, ]); return ( - 0}> - - {treeItems} - + + 0}> + + {treeItems} + + ); }); diff --git a/code/core/src/manager/components/sidebar/TreeNode.tsx b/code/core/src/manager/components/sidebar/TreeNode.tsx index 59dd02f04b10..df1276b623ce 100644 --- a/code/core/src/manager/components/sidebar/TreeNode.tsx +++ b/code/core/src/manager/components/sidebar/TreeNode.tsx @@ -46,14 +46,10 @@ const BranchNode = styled.button<{ gap: 6, paddingTop: 5, paddingBottom: 4, - - '&:hover, &:focus': { - background: transparentize(0.93, theme.color.secondary), - outline: 'none', - }, })); const LeafNode = styled.a<{ depth?: number }>(({ theme, depth = 0 }) => ({ + width: '100%', cursor: 'pointer', color: 'inherit', display: 'flex', diff --git a/code/core/src/manager/container/Menu.stories.tsx b/code/core/src/manager/container/Menu.stories.tsx index 0a2fd0c6b4d8..596042fb5a2f 100644 --- a/code/core/src/manager/container/Menu.stories.tsx +++ b/code/core/src/manager/container/Menu.stories.tsx @@ -1,5 +1,4 @@ -import type { FC, MouseEvent, PropsWithChildren, ReactElement } from 'react'; -import React, { Children, cloneElement } from 'react'; +import React from 'react'; import { TooltipLinkList, WithTooltip } from '@storybook/core/components'; import type { Meta, StoryObj } from '@storybook/react'; @@ -10,26 +9,6 @@ import { Shortcut } from './Menu'; const onLinkClick = action('onLinkClick'); -interface StoryLinkWrapperProps { - href: string; - passHref?: boolean; -} - -const StoryLinkWrapper: FC> = ({ - href, - passHref = false, - children, -}) => { - const child = Children.only(children) as ReactElement; - return cloneElement(child, { - href: passHref && href, - onClick: (e: MouseEvent) => { - e.preventDefault(); - onLinkClick(href); - }, - }); -}; - export default { component: TooltipLinkList, decorators: [ @@ -59,6 +38,7 @@ export const WithShortcuts = { center: 'This is an addition description', right: , href: 'http://google.com', + onClick: onLinkClick, }, { id: '2', @@ -66,9 +46,9 @@ export const WithShortcuts = { center: 'This is an addition description', right: , href: 'http://google.com', + onClick: onLinkClick, }, ], - LinkWrapper: StoryLinkWrapper, }, } satisfies Story; @@ -82,6 +62,7 @@ export const WithShortcutsActive = { active: true, right: , href: 'http://google.com', + onClick: onLinkClick, }, { id: '2', @@ -89,8 +70,8 @@ export const WithShortcutsActive = { center: 'This is an addition description', right: , href: 'http://google.com', + onClick: onLinkClick, }, ], - LinkWrapper: StoryLinkWrapper, }, } satisfies Story; diff --git a/code/core/src/manager/container/Sidebar.tsx b/code/core/src/manager/container/Sidebar.tsx index 61f0ff57c76d..bd405706068a 100755 --- a/code/core/src/manager/container/Sidebar.tsx +++ b/code/core/src/manager/container/Sidebar.tsx @@ -43,11 +43,8 @@ const Sidebar = React.memo(function Sideber({ onMenuClick }: SidebarProps) { const whatsNewNotificationsEnabled = state.whatsNewData?.status === 'SUCCESS' && !state.disableWhatsNewNotifications; - const bottomItems = api.getElements(Addon_TypesEnum.experimental_SIDEBAR_BOTTOM); const topItems = api.getElements(Addon_TypesEnum.experimental_SIDEBAR_TOP); // eslint-disable-next-line react-hooks/exhaustive-deps - const bottom = useMemo(() => Object.values(bottomItems), [Object.keys(bottomItems).join('')]); - // eslint-disable-next-line react-hooks/exhaustive-deps const top = useMemo(() => Object.values(topItems), [Object.keys(topItems).join('')]); return { @@ -64,7 +61,6 @@ const Sidebar = React.memo(function Sideber({ onMenuClick }: SidebarProps) { menu, menuHighlighted: whatsNewNotificationsEnabled && api.isWhatsNewUnread(), enableShortcuts, - bottom, extra: top, }; }; diff --git a/code/core/src/manager/globals/exports.ts b/code/core/src/manager/globals/exports.ts index f83d30717328..2596f0f01602 100644 --- a/code/core/src/manager/globals/exports.ts +++ b/code/core/src/manager/globals/exports.ts @@ -65,6 +65,8 @@ export default { 'AlignLeftIcon', 'AlignRightIcon', 'AppleIcon', + 'ArrowBottomLeftIcon', + 'ArrowBottomRightIcon', 'ArrowDownIcon', 'ArrowLeftIcon', 'ArrowRightIcon', @@ -72,6 +74,8 @@ export default { 'ArrowSolidLeftIcon', 'ArrowSolidRightIcon', 'ArrowSolidUpIcon', + 'ArrowTopLeftIcon', + 'ArrowTopRightIcon', 'ArrowUpIcon', 'AzureDevOpsIcon', 'BackIcon', @@ -244,6 +248,9 @@ export default { 'StackedIcon', 'StarHollowIcon', 'StarIcon', + 'StatusFailIcon', + 'StatusPassIcon', + 'StatusWarnIcon', 'StickerIcon', 'StopAltIcon', 'StopIcon', @@ -279,6 +286,7 @@ export default { 'WatchIcon', 'WindowsIcon', 'WrenchIcon', + 'XIcon', 'YoutubeIcon', 'ZoomIcon', 'ZoomOutIcon', @@ -777,6 +785,7 @@ export default { 'SELECT_STORY', 'SET_CONFIG', 'SET_CURRENT_STORY', + 'SET_FILTER', 'SET_GLOBALS', 'SET_INDEX', 'SET_STORIES', @@ -833,6 +842,7 @@ export default { 'SELECT_STORY', 'SET_CONFIG', 'SET_CURRENT_STORY', + 'SET_FILTER', 'SET_GLOBALS', 'SET_INDEX', 'SET_STORIES', @@ -889,6 +899,7 @@ export default { 'SELECT_STORY', 'SET_CONFIG', 'SET_CURRENT_STORY', + 'SET_FILTER', 'SET_GLOBALS', 'SET_INDEX', 'SET_STORIES', diff --git a/code/core/src/manager/utils/status.tsx b/code/core/src/manager/utils/status.tsx index 3474ba64b393..14c2c6a613c5 100644 --- a/code/core/src/manager/utils/status.tsx +++ b/code/core/src/manager/utils/status.tsx @@ -1,10 +1,11 @@ -import React from 'react'; import type { ReactElement } from 'react'; +import React from 'react'; import { styled } from '@storybook/core/theming'; import type { API_HashEntry, API_StatusState, API_StatusValue } from '@storybook/core/types'; import { CircleIcon } from '@storybook/icons'; +import { UseSymbol } from '../components/sidebar/IconSymbols'; import { getDescendantIds } from './tree'; const SmallIcons = styled(CircleIcon)({ @@ -25,9 +26,24 @@ export const statusPriority: API_StatusValue[] = ['unknown', 'pending', 'success export const statusMapping: Record = { unknown: [null, null], pending: [, 'currentColor'], - success: [, 'currentColor'], - warn: [, '#A15C20'], - error: [, 'brown'], + success: [ + + + , + 'currentColor', + ], + warn: [ + + + , + '#A15C20', + ], + error: [ + + + , + 'brown', + ], }; export const getHighestStatus = (statuses: API_StatusValue[]): API_StatusValue => { diff --git a/code/core/src/preview-api/index.ts b/code/core/src/preview-api/index.ts index 315db0327fd7..1ba17db79b3d 100644 --- a/code/core/src/preview-api/index.ts +++ b/code/core/src/preview-api/index.ts @@ -54,6 +54,7 @@ export { normalizeStory, filterArgTypes, sanitizeStoryContextUpdate, + setDefaultProjectAnnotations, setProjectAnnotations, inferControls, userOrAutoTitleFromSpecifier, diff --git a/code/core/src/preview-api/modules/store/csf/composeConfigs.ts b/code/core/src/preview-api/modules/store/csf/composeConfigs.ts index 7ccec5685659..545ab719e5a3 100644 --- a/code/core/src/preview-api/modules/store/csf/composeConfigs.ts +++ b/code/core/src/preview-api/modules/store/csf/composeConfigs.ts @@ -1,4 +1,4 @@ -import type { ModuleExports, ProjectAnnotations } from '@storybook/core/types'; +import type { ModuleExports, NormalizedProjectAnnotations } from '@storybook/core/types'; import type { Renderer } from '@storybook/core/types'; import { global } from '@storybook/global'; @@ -41,7 +41,7 @@ export function getSingletonField( export function composeConfigs( moduleExportList: ModuleExports[] -): ProjectAnnotations { +): NormalizedProjectAnnotations { const allArgTypeEnhancers = getArrayField(moduleExportList, 'argTypesEnhancers'); const stepRunners = getField(moduleExportList, 'runStep'); const beforeAllHooks = getArrayField(moduleExportList, 'beforeAll'); diff --git a/code/core/src/preview-api/modules/store/csf/portable-stories.ts b/code/core/src/preview-api/modules/store/csf/portable-stories.ts index 877901fecea4..a5491714a3e3 100644 --- a/code/core/src/preview-api/modules/store/csf/portable-stories.ts +++ b/code/core/src/preview-api/modules/store/csf/portable-stories.ts @@ -9,6 +9,7 @@ import type { ComposedStoryFn, LegacyStoryAnnotationsOrFn, NamedOrDefaultProjectAnnotations, + NormalizedProjectAnnotations, Parameters, PreparedStory, ProjectAnnotations, @@ -32,7 +33,20 @@ import { normalizeProjectAnnotations } from './normalizeProjectAnnotations'; import { normalizeStory } from './normalizeStory'; import { prepareContext, prepareStory } from './prepareStory'; -let globalProjectAnnotations: ProjectAnnotations = {}; +// TODO we should get to the bottom of the singleton issues caused by dual ESM/CJS modules +declare global { + // eslint-disable-next-line no-var + var globalProjectAnnotations: NormalizedProjectAnnotations; + // eslint-disable-next-line no-var + var defaultProjectAnnotations: ProjectAnnotations; +} + +export function setDefaultProjectAnnotations( + _defaultProjectAnnotations: ProjectAnnotations +) { + // Use a variable once we figure out the ESM/CJS issues + globalThis.defaultProjectAnnotations = _defaultProjectAnnotations; +} const DEFAULT_STORY_TITLE = 'ComposedStory'; const DEFAULT_STORY_NAME = 'Unnamed Story'; @@ -52,11 +66,11 @@ export function setProjectAnnotations( projectAnnotations: | NamedOrDefaultProjectAnnotations | NamedOrDefaultProjectAnnotations[] -): ProjectAnnotations { +): NormalizedProjectAnnotations { const annotations = Array.isArray(projectAnnotations) ? projectAnnotations : [projectAnnotations]; - globalProjectAnnotations = composeConfigs(annotations.map(extractAnnotation)); + globalThis.globalProjectAnnotations = composeConfigs(annotations.map(extractAnnotation)); - return globalProjectAnnotations; + return globalThis.globalProjectAnnotations; } const cleanups: CleanupCallback[] = []; @@ -93,7 +107,13 @@ export function composeStory( - composeConfigs([defaultConfig ?? {}, globalProjectAnnotations, projectAnnotations ?? {}]) + composeConfigs([ + defaultConfig && Object.keys(defaultConfig).length > 0 + ? defaultConfig + : globalThis.defaultProjectAnnotations ?? {}, + globalThis.globalProjectAnnotations ?? {}, + projectAnnotations ?? {}, + ]) ); const story = prepareStory( @@ -219,10 +239,13 @@ export function composeStory + composeStory(story, component, project, {}, exportsName); + export function composeStories( storiesImport: TModule, globalConfig: ProjectAnnotations, - composeStoryFn: ComposeStoryFn + composeStoryFn: ComposeStoryFn = defaultComposeStory ) { const { default: meta, __esModule, __namedExportsOrder, ...stories } = storiesImport; const composedStories = Object.entries(stories).reduce((storiesMap, [exportsName, story]) => { diff --git a/code/core/src/preview-errors.ts b/code/core/src/preview-errors.ts index a50183f79ac4..5fe03b827b8f 100644 --- a/code/core/src/preview-errors.ts +++ b/code/core/src/preview-errors.ts @@ -236,42 +236,6 @@ export class MountMustBeDestructuredError extends StorybookError { } } -export class TestingLibraryMustBeConfiguredError extends StorybookError { - constructor() { - super({ - category: Category.PREVIEW_API, - code: 13, - message: dedent` - You must configure testingLibraryRender to use play in portable stories. - - import { render } from '@testing-library/[renderer]'; - - setProjectAnnotations({ - testingLibraryRender: render, - }); - - For other testing renderers, you can configure \`renderToCanvas\` like so: - - import { render } from 'your-test-renderer'; - - setProjectAnnotations({ - renderToCanvas: ({ storyFn }) => { - const Story = storyFn(); - - // Svelte - render(Story.Component, Story.props); - - // Vue - render(Story); - - // or for React - render(); - }, - });`, - }); - } -} - export class NoRenderFunctionError extends StorybookError { constructor(public data: { id: string }) { super({ diff --git a/code/core/src/server-errors.ts b/code/core/src/server-errors.ts index 23e203f27ddb..bb75baa0929d 100644 --- a/code/core/src/server-errors.ts +++ b/code/core/src/server-errors.ts @@ -379,16 +379,30 @@ export class MainFileESMOnlyImportError extends StorybookError { } export class MainFileMissingError extends StorybookError { - constructor(public data: { location: string }) { + constructor(public data: { location: string; source?: 'storybook' | 'vitest' }) { + const map = { + storybook: { + helperMessage: + 'You can pass a --config-dir flag to tell Storybook, where your main.js file is located at.', + documentation: 'https://storybook.js.org/docs/configure', + }, + vitest: { + helperMessage: + 'You can pass a configDir plugin option to tell where your main.js file is located at.', + // TODO: add proper docs once available + documentation: 'https://storybook.js.org/docs/configure', + }, + }; + const { documentation, helperMessage } = map[data.source || 'storybook']; super({ category: Category.CORE_SERVER, code: 6, - documentation: 'https://storybook.js.org/docs/configure', + documentation, message: dedent` No configuration files have been found in your configDir: ${chalk.yellow(data.location)}. Storybook needs a "main.js" file, please add it. - You can pass a --config-dir flag to tell Storybook, where your main.js file is located at).`, + ${helperMessage}`, }); } } diff --git a/code/core/src/telemetry/get-portable-stories-usage.test.ts b/code/core/src/telemetry/get-portable-stories-usage.test.ts index 8fa4c9292607..604b65e8a37a 100644 --- a/code/core/src/telemetry/get-portable-stories-usage.test.ts +++ b/code/core/src/telemetry/get-portable-stories-usage.test.ts @@ -9,7 +9,7 @@ const mocksDir = join(__dirname, '..', '__mocks__'); describe('getPortableStoriesFileCountUncached', () => { it('should ignores node_modules, non-source files', async () => { const usage = await getPortableStoriesFileCountUncached(mocksDir); - // you can verify with: `git grep -m1 -c composeStor | wc -l` + // you can verify with: `git grep -l composeStor | wc -l` expect(usage).toMatchInlineSnapshot(`2`); }); }); diff --git a/code/core/src/telemetry/get-portable-stories-usage.ts b/code/core/src/telemetry/get-portable-stories-usage.ts index a6b92019d00e..e328a3283c57 100644 --- a/code/core/src/telemetry/get-portable-stories-usage.ts +++ b/code/core/src/telemetry/get-portable-stories-usage.ts @@ -9,11 +9,12 @@ const cache = createFileSystemCache({ }); export const getPortableStoriesFileCountUncached = async (path?: string) => { - const command = `git grep -m1 -c composeStor` + (path ? ` -- ${path}` : ''); + const command = `git grep -l composeStor` + (path ? ` -- ${path}` : ''); const { stdout } = await execaCommand(command, { cwd: process.cwd(), shell: true, }); + return stdout.split('\n').filter(Boolean).length; }; diff --git a/code/core/src/types/modules/addons.ts b/code/core/src/types/modules/addons.ts index 7dceb312cccc..41e77b4c0103 100644 --- a/code/core/src/types/modules/addons.ts +++ b/code/core/src/types/modules/addons.ts @@ -431,6 +431,10 @@ export interface Addon_WrapperType { }> >; } + +/** + * @deprecated This doesn't do anything anymore and will be removed in Storybook 9.0. + */ export interface Addon_SidebarBottomType { type: Addon_TypesEnum.experimental_SIDEBAR_BOTTOM; /** @@ -443,6 +447,9 @@ export interface Addon_SidebarBottomType { render: FC; } +/** + * @deprecated This will be removed in Storybook 9.0. + */ export interface Addon_SidebarTopType { type: Addon_TypesEnum.experimental_SIDEBAR_TOP; /** @@ -523,12 +530,12 @@ export enum Addon_TypesEnum { experimental_PAGE = 'page', /** * This adds items in the bottom of the sidebar. - * @unstable + * @deprecated This doesn't do anything anymore and will be removed in Storybook 9.0. */ experimental_SIDEBAR_BOTTOM = 'sidebar-bottom', /** * This adds items in the top of the sidebar. - * @unstable This will get replaced with a new API in 8.0, use at your own risk. + * @deprecated This will be removed in Storybook 9.0. */ experimental_SIDEBAR_TOP = 'sidebar-top', } diff --git a/code/core/src/types/modules/api-stories.ts b/code/core/src/types/modules/api-stories.ts index 7bde00d74f7b..92d4014a0018 100644 --- a/code/core/src/types/modules/api-stories.ts +++ b/code/core/src/types/modules/api-stories.ts @@ -125,6 +125,7 @@ export interface API_StatusObject { title: string; description: string; data?: any; + onClick?: () => void; } export type API_StatusState = Record>; diff --git a/code/core/template/stories/args.stories.ts b/code/core/template/stories/args.stories.ts index c5beee938600..4f3b766b5a30 100644 --- a/code/core/template/stories/args.stories.ts +++ b/code/core/template/stories/args.stories.ts @@ -67,6 +67,7 @@ export const Targets = { b: 'b', }); }, + tags: ['!vitest'], }; export const Events = { @@ -85,4 +86,5 @@ export const Events = { await new Promise((resolve) => channel.once(STORY_ARGS_UPDATED, resolve)); await within(canvasElement).findByText(/updated/); }, + tags: ['!vitest'], }; diff --git a/code/core/template/stories/decorators.stories.ts b/code/core/template/stories/decorators.stories.ts index e04fbf5e6373..aa5cb1b6d496 100644 --- a/code/core/template/stories/decorators.stories.ts +++ b/code/core/template/stories/decorators.stories.ts @@ -67,4 +67,5 @@ export const Hooks = { }); await new Promise((resolve) => channel.once(STORY_ARGS_UPDATED, resolve)); }, + tags: ['!vitest'], }; diff --git a/code/core/template/stories/globals.stories.ts b/code/core/template/stories/globals.stories.ts index 84a0c86d2ee2..0afd7d1fc44d 100644 --- a/code/core/template/stories/globals.stories.ts +++ b/code/core/template/stories/globals.stories.ts @@ -44,6 +44,7 @@ export const Events = { await channel.emit('updateGlobals', { globals: { foo: 'fooValue' } }); await within(canvasElement).findByText('fooValue'); }, + tags: ['!vitest'], }; export const Overrides1 = { diff --git a/code/core/template/stories/rendering.stories.ts b/code/core/template/stories/rendering.stories.ts index 7e03897e3c02..f4206b006123 100644 --- a/code/core/template/stories/rendering.stories.ts +++ b/code/core/template/stories/rendering.stories.ts @@ -14,6 +14,7 @@ export default { args: { label: 'Click me', }, + tags: ['!vitest'], }; export const ForceRemount = { @@ -37,7 +38,7 @@ export const ForceRemount = { // By forcing the component to remount, we reset the focus state await channel.emit(FORCE_REMOUNT, { storyId: id }); }, - tags: ['!test'], + tags: ['!test', '!vitest'], }; export const ChangeArgs = { diff --git a/code/core/template/stories/tags-add.stories.ts b/code/core/template/stories/tags-add.stories.ts index 9a04ebe25bd4..f4b15f2557bf 100644 --- a/code/core/template/stories/tags-add.stories.ts +++ b/code/core/template/stories/tags-add.stories.ts @@ -16,7 +16,7 @@ export default { }; export const Inheritance = { - tags: ['story-one'], + tags: ['story-one', '!vitest'], play: async ({ canvasElement }: PlayFunctionContext) => { const canvas = within(canvasElement); await expect(JSON.parse(canvas.getByTestId('pre').innerText)).toEqual({ diff --git a/code/core/template/stories/tags-config.stories.ts b/code/core/template/stories/tags-config.stories.ts index 9d7b70f8478a..0f3269d4d4a1 100644 --- a/code/core/template/stories/tags-config.stories.ts +++ b/code/core/template/stories/tags-config.stories.ts @@ -16,7 +16,7 @@ export default { }; export const Inheritance = { - tags: ['story-one'], + tags: ['story-one', '!vitest'], play: async ({ canvasElement }: PlayFunctionContext) => { const canvas = within(canvasElement); await expect(JSON.parse(canvas.getByTestId('pre').innerText)).toEqual({ diff --git a/code/core/template/stories/tags-remove.stories.ts b/code/core/template/stories/tags-remove.stories.ts index f0148e6fb5b5..cc05e9b23208 100644 --- a/code/core/template/stories/tags-remove.stories.ts +++ b/code/core/template/stories/tags-remove.stories.ts @@ -16,7 +16,7 @@ export default { }; export const Inheritance = { - tags: ['story-one'], + tags: ['story-one', '!vitest'], play: async ({ canvasElement }: PlayFunctionContext) => { const canvas = within(canvasElement); await expect(JSON.parse(canvas.getByTestId('pre').innerText)).toEqual({ diff --git a/code/frameworks/experimental-nextjs-vite/src/portable-stories.ts b/code/frameworks/experimental-nextjs-vite/src/portable-stories.ts index 7ad73e7ce778..7738e84ace2a 100644 --- a/code/frameworks/experimental-nextjs-vite/src/portable-stories.ts +++ b/code/frameworks/experimental-nextjs-vite/src/portable-stories.ts @@ -3,11 +3,13 @@ import { composeStories as originalComposeStories, composeStory as originalComposeStory, setProjectAnnotations as originalSetProjectAnnotations, + setDefaultProjectAnnotations, } from 'storybook/internal/preview-api'; import type { Args, ComposedStoryFn, NamedOrDefaultProjectAnnotations, + NormalizedProjectAnnotations, ProjectAnnotations, Store_CSFExports, StoriesWithPartialProps, @@ -16,7 +18,6 @@ import type { import type { Meta, ReactRenderer } from '@storybook/react'; -import * as rscAnnotations from '../../../renderers/react/src/entry-preview-rsc'; // ! ATTENTION: This needs to be a relative import so it gets prebundled. This is to avoid ESM issues in Nextjs + Jest setups import { INTERNAL_DEFAULT_PROJECT_ANNOTATIONS as reactAnnotations } from '../../../renderers/react/src/portable-stories'; import * as nextJsAnnotations from './preview'; @@ -28,7 +29,7 @@ import * as nextJsAnnotations from './preview'; * Example: *```jsx * // setup.js (for jest) - * import { setProjectAnnotations } from '@storybook/nextjs'; + * import { setProjectAnnotations } from '@storybook/experimental-nextjs-vite'; * import projectAnnotations from './.storybook/preview'; * * setProjectAnnotations(projectAnnotations); @@ -38,16 +39,18 @@ import * as nextJsAnnotations from './preview'; */ export function setProjectAnnotations( projectAnnotations: - | NamedOrDefaultProjectAnnotations - | NamedOrDefaultProjectAnnotations[] -): ProjectAnnotations { - return originalSetProjectAnnotations(projectAnnotations); + | NamedOrDefaultProjectAnnotations + | NamedOrDefaultProjectAnnotations[] +): NormalizedProjectAnnotations { + setDefaultProjectAnnotations(INTERNAL_DEFAULT_PROJECT_ANNOTATIONS); + return originalSetProjectAnnotations( + projectAnnotations + ) as NormalizedProjectAnnotations; } // This will not be necessary once we have auto preset loading -const defaultProjectAnnotations: ProjectAnnotations = composeConfigs([ +const INTERNAL_DEFAULT_PROJECT_ANNOTATIONS: ProjectAnnotations = composeConfigs([ reactAnnotations, - rscAnnotations, nextJsAnnotations, ]); @@ -62,7 +65,7 @@ const defaultProjectAnnotations: ProjectAnnotations = composeConf * Example: *```jsx * import { render } from '@testing-library/react'; - * import { composeStory } from '@storybook/nextjs'; + * import { composeStory } from '@storybook/experimental-nextjs-vite'; * import Meta, { Primary as PrimaryStory } from './Button.stories'; * * const Primary = composeStory(PrimaryStory, Meta); @@ -88,7 +91,7 @@ export function composeStory( story as StoryAnnotationsOrFn, componentAnnotations, projectAnnotations, - defaultProjectAnnotations, + INTERNAL_DEFAULT_PROJECT_ANNOTATIONS, exportsName ); } @@ -104,7 +107,7 @@ export function composeStory( * Example: *```jsx * import { render } from '@testing-library/react'; - * import { composeStories } from '@storybook/nextjs'; + * import { composeStories } from '@storybook/experimental-nextjs-vite'; * import * as stories from './Button.stories'; * * const { Primary, Secondary } = composeStories(stories); diff --git a/code/frameworks/experimental-nextjs-vite/template/stories/NextHeader.stories.tsx b/code/frameworks/experimental-nextjs-vite/template/stories/NextHeader.stories.tsx index c0ec7f1bbba9..178aea8c3ac8 100644 --- a/code/frameworks/experimental-nextjs-vite/template/stories/NextHeader.stories.tsx +++ b/code/frameworks/experimental-nextjs-vite/template/stories/NextHeader.stories.tsx @@ -8,6 +8,7 @@ import NextHeader from './NextHeader'; export default { component: NextHeader, + parameters: { react: { rsc: true } }, } as Meta; type Story = StoryObj; diff --git a/code/frameworks/experimental-nextjs-vite/template/stories/RSC.stories.jsx b/code/frameworks/experimental-nextjs-vite/template/stories/RSC.stories.jsx index 1847c024379c..f5520448bd61 100644 --- a/code/frameworks/experimental-nextjs-vite/template/stories/RSC.stories.jsx +++ b/code/frameworks/experimental-nextjs-vite/template/stories/RSC.stories.jsx @@ -5,6 +5,11 @@ import { Nested, RSC } from './RSC'; export default { component: RSC, args: { label: 'label' }, + parameters: { + react: { + rsc: true, + }, + }, }; export const Default = {}; @@ -18,7 +23,7 @@ export const DisableRSC = { }; export const Error = { - tags: ['!test'], + tags: ['!test', '!vitest'], parameters: { chromatic: { disable: true }, }, diff --git a/code/frameworks/experimental-nextjs-vite/template/stories/Redirect.stories.tsx b/code/frameworks/experimental-nextjs-vite/template/stories/Redirect.stories.tsx index 3c5980b79757..47fd4c5228d1 100644 --- a/code/frameworks/experimental-nextjs-vite/template/stories/Redirect.stories.tsx +++ b/code/frameworks/experimental-nextjs-vite/template/stories/Redirect.stories.tsx @@ -46,7 +46,7 @@ export default { }, }, }, - tags: ['!test'], + tags: ['!test', '!vitest'], } as Meta; export const SingletonStateGetsInvalidatedAfterRedirecting: StoryObj = { diff --git a/code/frameworks/nextjs/src/portable-stories.ts b/code/frameworks/nextjs/src/portable-stories.ts index 7ad73e7ce778..26e7a6f738b0 100644 --- a/code/frameworks/nextjs/src/portable-stories.ts +++ b/code/frameworks/nextjs/src/portable-stories.ts @@ -3,11 +3,13 @@ import { composeStories as originalComposeStories, composeStory as originalComposeStory, setProjectAnnotations as originalSetProjectAnnotations, + setDefaultProjectAnnotations, } from 'storybook/internal/preview-api'; import type { Args, ComposedStoryFn, NamedOrDefaultProjectAnnotations, + NormalizedProjectAnnotations, ProjectAnnotations, Store_CSFExports, StoriesWithPartialProps, @@ -16,7 +18,6 @@ import type { import type { Meta, ReactRenderer } from '@storybook/react'; -import * as rscAnnotations from '../../../renderers/react/src/entry-preview-rsc'; // ! ATTENTION: This needs to be a relative import so it gets prebundled. This is to avoid ESM issues in Nextjs + Jest setups import { INTERNAL_DEFAULT_PROJECT_ANNOTATIONS as reactAnnotations } from '../../../renderers/react/src/portable-stories'; import * as nextJsAnnotations from './preview'; @@ -38,16 +39,18 @@ import * as nextJsAnnotations from './preview'; */ export function setProjectAnnotations( projectAnnotations: - | NamedOrDefaultProjectAnnotations - | NamedOrDefaultProjectAnnotations[] -): ProjectAnnotations { - return originalSetProjectAnnotations(projectAnnotations); + | NamedOrDefaultProjectAnnotations + | NamedOrDefaultProjectAnnotations[] +): NormalizedProjectAnnotations { + setDefaultProjectAnnotations(INTERNAL_DEFAULT_PROJECT_ANNOTATIONS); + return originalSetProjectAnnotations( + projectAnnotations + ) as NormalizedProjectAnnotations; } // This will not be necessary once we have auto preset loading -const defaultProjectAnnotations: ProjectAnnotations = composeConfigs([ +const INTERNAL_DEFAULT_PROJECT_ANNOTATIONS: ProjectAnnotations = composeConfigs([ reactAnnotations, - rscAnnotations, nextJsAnnotations, ]); @@ -88,7 +91,7 @@ export function composeStory( story as StoryAnnotationsOrFn, componentAnnotations, projectAnnotations, - defaultProjectAnnotations, + INTERNAL_DEFAULT_PROJECT_ANNOTATIONS, exportsName ); } diff --git a/code/frameworks/nextjs/template/stories/RSC.stories.jsx b/code/frameworks/nextjs/template/stories/RSC.stories.jsx index 1847c024379c..774e129cbd58 100644 --- a/code/frameworks/nextjs/template/stories/RSC.stories.jsx +++ b/code/frameworks/nextjs/template/stories/RSC.stories.jsx @@ -5,6 +5,11 @@ import { Nested, RSC } from './RSC'; export default { component: RSC, args: { label: 'label' }, + parameters: { + react: { + rsc: true, + }, + }, }; export const Default = {}; diff --git a/code/frameworks/nextjs/template/stories/dynamic-component.js b/code/frameworks/nextjs/template/stories/dynamic-component.jsx similarity index 100% rename from code/frameworks/nextjs/template/stories/dynamic-component.js rename to code/frameworks/nextjs/template/stories/dynamic-component.jsx diff --git a/code/frameworks/nextjs/template/stories_nextjs-default-ts/NextHeader.stories.tsx b/code/frameworks/nextjs/template/stories_nextjs-default-ts/NextHeader.stories.tsx index 1c1a7b9bca20..059bcbcff7b7 100644 --- a/code/frameworks/nextjs/template/stories_nextjs-default-ts/NextHeader.stories.tsx +++ b/code/frameworks/nextjs/template/stories_nextjs-default-ts/NextHeader.stories.tsx @@ -7,6 +7,11 @@ import NextHeader from './NextHeader'; export default { component: NextHeader, + parameters: { + react: { + rsc: true, + }, + }, } as Meta; type Story = StoryObj; diff --git a/code/frameworks/sveltekit/template/stories_svelte-kit-skeleton-js/Forms.svelte b/code/frameworks/sveltekit/template/stories_svelte-kit-skeleton-js/Forms.svelte index 371a17656bea..8513ae2a7064 100644 --- a/code/frameworks/sveltekit/template/stories_svelte-kit-skeleton-js/Forms.svelte +++ b/code/frameworks/sveltekit/template/stories_svelte-kit-skeleton-js/Forms.svelte @@ -2,6 +2,6 @@ import { enhance } from '$app/forms'; -
+
\ No newline at end of file diff --git a/code/frameworks/sveltekit/template/stories_svelte-kit-skeleton-js/forms.stories.js b/code/frameworks/sveltekit/template/stories_svelte-kit-skeleton-js/forms.stories.js index baff059fa212..7659291df4f8 100644 --- a/code/frameworks/sveltekit/template/stories_svelte-kit-skeleton-js/forms.stories.js +++ b/code/frameworks/sveltekit/template/stories_svelte-kit-skeleton-js/forms.stories.js @@ -1,4 +1,4 @@ -import { expect, fn, within } from '@storybook/test'; +import { expect, fn, userEvent, within } from '@storybook/test'; import Forms from './Forms.svelte'; @@ -13,8 +13,8 @@ const enhance = fn(); export const Enhance = { async play({ canvasElement }) { const canvas = within(canvasElement); - const button = canvas.getByText('enhance'); - button.click(); + const button = canvas.getByRole('button'); + await userEvent.click(button); expect(enhance).toHaveBeenCalled(); }, parameters: { diff --git a/code/lib/blocks/package.json b/code/lib/blocks/package.json index 573c62cef99a..729235062c5f 100644 --- a/code/lib/blocks/package.json +++ b/code/lib/blocks/package.json @@ -46,7 +46,7 @@ "dependencies": { "@storybook/csf": "^0.1.11", "@storybook/global": "^5.0.0", - "@storybook/icons": "^1.2.5", + "@storybook/icons": "^1.2.10", "@types/lodash": "^4.14.167", "color-convert": "^2.0.1", "dequal": "^2.0.2", diff --git a/code/lib/cli-storybook/src/sandbox-templates.ts b/code/lib/cli-storybook/src/sandbox-templates.ts index 3498e6d0113a..c2be9be04f51 100644 --- a/code/lib/cli-storybook/src/sandbox-templates.ts +++ b/code/lib/cli-storybook/src/sandbox-templates.ts @@ -5,6 +5,7 @@ export type SkippableTask = | 'smoke-test' | 'test-runner' | 'test-runner-dev' + | 'vitest-integration' | 'chromatic' | 'e2e-tests' | 'e2e-tests-dev' @@ -105,7 +106,7 @@ const baseTemplates = { renderer: '@storybook/react', builder: '@storybook/builder-webpack5', }, - skipTasks: ['e2e-tests-dev', 'bench'], + skipTasks: ['e2e-tests-dev', 'bench', 'vitest-integration'], modifications: { mainConfig: (config) => { const stories = config.getFieldValue>(['stories']); @@ -128,7 +129,7 @@ const baseTemplates = { jq '.browserslist.production[0] = ">0.9%"' package.json > tmp.json && mv tmp.json package.json `, // Re-enable once https://github.com/storybookjs/storybook/issues/19351 is fixed. - skipTasks: ['smoke-test', 'bench'], + skipTasks: ['smoke-test', 'bench', 'vitest-integration'], expected: { // TODO: change this to @storybook/cra once that package is created framework: '@storybook/react-webpack5', @@ -151,7 +152,7 @@ const baseTemplates = { }, extraDependencies: ['server-only'], }, - skipTasks: ['e2e-tests-dev', 'bench'], + skipTasks: ['e2e-tests-dev', 'bench', 'vitest-integration'], }, 'nextjs/default-js': { name: 'Next.js Latest (Webpack | JavaScript)', @@ -168,7 +169,7 @@ const baseTemplates = { }, extraDependencies: ['server-only'], }, - skipTasks: ['e2e-tests-dev', 'bench'], + skipTasks: ['e2e-tests-dev', 'bench', 'vitest-integration'], }, 'nextjs/default-ts': { name: 'Next.js Latest (Webpack | TypeScript)', @@ -185,7 +186,7 @@ const baseTemplates = { }, extraDependencies: ['server-only'], }, - skipTasks: ['e2e-tests-dev', 'bench'], + skipTasks: ['e2e-tests-dev', 'bench', 'vitest-integration'], }, 'nextjs/prerelease': { name: 'Next.js Prerelease (Webpack | TypeScript)', @@ -202,7 +203,7 @@ const baseTemplates = { features: { experimentalRSC: true }, }, }, - skipTasks: ['e2e-tests-dev', 'bench'], + skipTasks: ['e2e-tests-dev', 'bench', 'vitest-integration'], }, 'experimental-nextjs-vite/default-ts': { name: 'Next.js Latest (Vite | TypeScript)', @@ -277,7 +278,7 @@ const baseTemplates = { renderer: '@storybook/react', builder: '@storybook/builder-webpack5', }, - skipTasks: ['e2e-tests-dev', 'bench'], + skipTasks: ['e2e-tests-dev', 'bench', 'vitest-integration'], }, 'react-webpack/17-ts': { name: 'React v17 (Webpack | TypeScript)', @@ -288,7 +289,7 @@ const baseTemplates = { renderer: '@storybook/react', builder: '@storybook/builder-webpack5', }, - skipTasks: ['e2e-tests-dev', 'bench'], + skipTasks: ['e2e-tests-dev', 'bench', 'vitest-integration'], }, 'react-webpack/prerelease-ts': { name: 'React Prerelease (Webpack | TypeScript)', @@ -308,7 +309,7 @@ const baseTemplates = { renderer: '@storybook/react', builder: '@storybook/builder-webpack5', }, - skipTasks: ['e2e-tests-dev', 'bench'], + skipTasks: ['e2e-tests-dev', 'bench', 'vitest-integration'], }, 'solid-vite/default-js': { name: 'SolidJS Latest (Vite | JavaScript)', @@ -320,7 +321,7 @@ const baseTemplates = { }, // TODO: remove this once solid-vite framework is released inDevelopment: true, - skipTasks: ['e2e-tests-dev', 'bench'], + skipTasks: ['e2e-tests-dev', 'bench', 'vitest-integration'], }, 'solid-vite/default-ts': { name: 'SolidJS Latest (Vite | TypeScript)', @@ -362,7 +363,7 @@ const baseTemplates = { renderer: '@storybook/html', builder: '@storybook/builder-webpack5', }, - skipTasks: ['e2e-tests-dev', 'bench'], + skipTasks: ['e2e-tests-dev', 'bench', 'vitest-integration'], }, 'html-vite/default-js': { name: 'HTML Latest (Vite | JavaScript)', @@ -373,7 +374,7 @@ const baseTemplates = { renderer: '@storybook/html', builder: '@storybook/builder-vite', }, - skipTasks: ['e2e-tests-dev', 'bench'], + skipTasks: ['e2e-tests-dev', 'bench', 'vitest-integration'], }, 'html-vite/default-ts': { name: 'HTML Latest (Vite | TypeScript)', @@ -384,7 +385,7 @@ const baseTemplates = { renderer: '@storybook/html', builder: '@storybook/builder-vite', }, - skipTasks: ['e2e-tests-dev', 'bench'], + skipTasks: ['e2e-tests-dev', 'bench', 'vitest-integration'], }, 'svelte-vite/default-js': { name: 'Svelte Latest (Vite | JavaScript)', @@ -416,7 +417,7 @@ const baseTemplates = { renderer: '@storybook/angular', builder: '@storybook/builder-webpack5', }, - skipTasks: ['e2e-tests-dev', 'bench'], + skipTasks: ['e2e-tests-dev', 'bench', 'vitest-integration'], }, 'angular-cli/default-ts': { name: 'Angular CLI Latest (Webpack | TypeScript)', @@ -427,7 +428,7 @@ const baseTemplates = { renderer: '@storybook/angular', builder: '@storybook/builder-webpack5', }, - skipTasks: ['e2e-tests-dev', 'bench'], + skipTasks: ['e2e-tests-dev', 'bench', 'vitest-integration'], }, 'angular-cli/15-ts': { name: 'Angular CLI v15 (Webpack | TypeScript)', @@ -438,7 +439,7 @@ const baseTemplates = { renderer: '@storybook/angular', builder: '@storybook/builder-webpack5', }, - skipTasks: ['e2e-tests-dev', 'bench'], + skipTasks: ['e2e-tests-dev', 'bench', 'vitest-integration'], }, 'svelte-kit/skeleton-js': { name: 'SvelteKit Latest (Vite | JavaScript)', @@ -449,7 +450,8 @@ const baseTemplates = { renderer: '@storybook/svelte', builder: '@storybook/builder-vite', }, - skipTasks: ['e2e-tests-dev', 'bench'], + // TODO: remove vitest-integration filter once project annotations exist for sveltekit + skipTasks: ['e2e-tests-dev', 'bench', 'vitest-integration'], }, 'svelte-kit/skeleton-ts': { name: 'SvelteKit Latest (Vite | TypeScript)', @@ -460,7 +462,8 @@ const baseTemplates = { renderer: '@storybook/svelte', builder: '@storybook/builder-vite', }, - skipTasks: ['e2e-tests-dev', 'bench'], + // TODO: remove vitest-integration filter once project annotations exist for sveltekit + skipTasks: ['e2e-tests-dev', 'bench', 'vitest-integration'], }, 'svelte-kit/prerelease-ts': { name: 'SvelteKit Prerelease (Vite | TypeScript)', @@ -471,7 +474,7 @@ const baseTemplates = { renderer: '@storybook/svelte', builder: '@storybook/builder-vite', }, - skipTasks: ['e2e-tests-dev', 'bench'], + skipTasks: ['e2e-tests-dev', 'bench', 'vitest-integration'], }, 'lit-vite/default-js': { name: 'Lit Latest (Vite | JavaScript)', @@ -483,7 +486,7 @@ const baseTemplates = { builder: '@storybook/builder-vite', }, // Remove smoke-test from the list once https://github.com/storybookjs/storybook/issues/19351 is fixed. - skipTasks: ['smoke-test', 'e2e-tests-dev', 'bench'], + skipTasks: ['smoke-test', 'e2e-tests-dev', 'bench', 'vitest-integration'], }, 'lit-vite/default-ts': { name: 'Lit Latest (Vite | TypeScript)', @@ -495,7 +498,7 @@ const baseTemplates = { builder: '@storybook/builder-vite', }, // Remove smoke-test from the list once https://github.com/storybookjs/storybook/issues/19351 is fixed. - skipTasks: ['smoke-test', 'e2e-tests-dev', 'bench'], + skipTasks: ['smoke-test', 'e2e-tests-dev', 'bench', 'vitest-integration'], }, 'vue-cli/default-js': { name: 'Vue CLI v3 (Webpack | JavaScript)', @@ -507,7 +510,7 @@ const baseTemplates = { builder: '@storybook/builder-webpack5', }, // Remove smoke-test from the list once https://github.com/storybookjs/storybook/issues/19351 is fixed. - skipTasks: ['smoke-test', 'e2e-tests-dev', 'bench'], + skipTasks: ['smoke-test', 'e2e-tests-dev', 'bench', 'vitest-integration'], }, 'preact-vite/default-js': { name: 'Preact Latest (Vite | JavaScript)', @@ -520,7 +523,7 @@ const baseTemplates = { modifications: { extraDependencies: ['preact-render-to-string'], }, - skipTasks: ['e2e-tests-dev', 'bench'], + skipTasks: ['e2e-tests-dev', 'bench', 'vitest-integration'], }, 'preact-vite/default-ts': { name: 'Preact Latest (Vite | TypeScript)', @@ -533,7 +536,7 @@ const baseTemplates = { modifications: { extraDependencies: ['preact-render-to-string'], }, - skipTasks: ['e2e-tests-dev', 'bench'], + skipTasks: ['e2e-tests-dev', 'bench', 'vitest-integration'], }, 'qwik-vite/default-ts': { name: 'Qwik CLI Latest (Vite | TypeScript)', @@ -546,7 +549,7 @@ const baseTemplates = { builder: 'storybook-framework-qwik', }, // TODO: The community template does not provide standard stories, which is required for e2e tests. - skipTasks: ['e2e-tests', 'e2e-tests-dev', 'bench'], + skipTasks: ['e2e-tests', 'e2e-tests-dev', 'bench', 'vitest-integration'], }, 'ember/3-js': { name: 'Ember v3 (Webpack | JavaScript)', @@ -593,7 +596,7 @@ const internalTemplates = { ), }, isInternal: true, - skipTasks: ['e2e-tests-dev', 'bench'], + skipTasks: ['e2e-tests-dev', 'bench', 'vitest-integration'], }, 'internal/react16-webpack': { name: 'React 16 (Webpack | TypeScript)', @@ -604,7 +607,7 @@ const internalTemplates = { renderer: '@storybook/react', builder: '@storybook/builder-webpack5', }, - skipTasks: ['e2e-tests-dev', 'bench'], + skipTasks: ['e2e-tests-dev', 'bench', 'vitest-integration'], isInternal: true, }, 'internal/server-webpack5': { @@ -616,7 +619,7 @@ const internalTemplates = { builder: '@storybook/builder-webpack5', }, isInternal: true, - skipTasks: ['bench'], + skipTasks: ['bench', 'vitest-integration'], }, // 'internal/pnp': { // ...baseTemplates['cra/default-ts'], @@ -635,7 +638,14 @@ const benchTemplates = { modifications: { skipTemplateStories: true, }, - skipTasks: ['e2e-tests-dev', 'test-runner', 'test-runner-dev', 'e2e-tests', 'chromatic'], + skipTasks: [ + 'e2e-tests-dev', + 'test-runner', + 'test-runner-dev', + 'e2e-tests', + 'chromatic', + 'vitest-integration', + ], }, 'bench/react-webpack-18-ts': { ...baseTemplates['react-webpack/18-ts'], @@ -644,7 +654,14 @@ const benchTemplates = { modifications: { skipTemplateStories: true, }, - skipTasks: ['e2e-tests-dev', 'test-runner', 'test-runner-dev', 'e2e-tests', 'chromatic'], + skipTasks: [ + 'e2e-tests-dev', + 'test-runner', + 'test-runner-dev', + 'e2e-tests', + 'chromatic', + 'vitest-integration', + ], }, 'bench/react-vite-default-ts-nodocs': { ...baseTemplates['react-vite/default-ts'], @@ -654,7 +671,14 @@ const benchTemplates = { skipTemplateStories: true, disableDocs: true, }, - skipTasks: ['e2e-tests-dev', 'test-runner', 'test-runner-dev', 'e2e-tests', 'chromatic'], + skipTasks: [ + 'e2e-tests-dev', + 'test-runner', + 'test-runner-dev', + 'e2e-tests', + 'chromatic', + 'vitest-integration', + ], }, 'bench/react-vite-default-ts-test-build': { ...baseTemplates['react-vite/default-ts'], @@ -664,7 +688,13 @@ const benchTemplates = { skipTemplateStories: true, testBuild: true, }, - skipTasks: ['e2e-tests-dev', 'test-runner', 'test-runner-dev', 'e2e-tests'], + skipTasks: [ + 'e2e-tests-dev', + 'test-runner', + 'test-runner-dev', + 'e2e-tests', + 'vitest-integration', + ], }, 'bench/react-webpack-18-ts-test-build': { ...baseTemplates['react-webpack/18-ts'], @@ -674,7 +704,13 @@ const benchTemplates = { skipTemplateStories: true, testBuild: true, }, - skipTasks: ['e2e-tests-dev', 'test-runner', 'test-runner-dev', 'e2e-tests'], + skipTasks: [ + 'e2e-tests-dev', + 'test-runner', + 'test-runner-dev', + 'e2e-tests', + 'vitest-integration', + ], }, } satisfies Record; @@ -693,6 +729,7 @@ export const normal: TemplateKey[] = [ 'svelte-vite/default-ts', 'svelte-kit/skeleton-ts', 'nextjs/default-ts', + 'experimental-nextjs-vite/default-ts', 'bench/react-vite-default-ts', 'bench/react-webpack-18-ts', 'bench/react-vite-default-ts-nodocs', @@ -715,7 +752,6 @@ export const daily: TemplateKey[] = [ ...merged, 'angular-cli/prerelease', 'cra/default-js', - 'experimental-nextjs-vite/default-ts', 'react-vite/default-js', 'react-vite/prerelease-ts', 'react-webpack/prerelease-ts', diff --git a/code/lib/react-dom-shim/src/preventActChecks.tsx b/code/lib/react-dom-shim/src/preventActChecks.tsx new file mode 100644 index 000000000000..f35e2fb25dc5 --- /dev/null +++ b/code/lib/react-dom-shim/src/preventActChecks.tsx @@ -0,0 +1,17 @@ +export {}; + +declare const globalThis: { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}; + +// TODO(9.0): We should actually wrap all those lines in `act`, but that might be a breaking change. +// We should make that breaking change for SB 9.0 +export function preventActChecks(callback: () => void): void { + const originalActEnvironment = globalThis.IS_REACT_ACT_ENVIRONMENT; + globalThis.IS_REACT_ACT_ENVIRONMENT = false; + try { + callback(); + } finally { + globalThis.IS_REACT_ACT_ENVIRONMENT = originalActEnvironment; + } +} diff --git a/code/lib/react-dom-shim/src/react-16.tsx b/code/lib/react-dom-shim/src/react-16.tsx index 8c7b2c8f5a67..a1e7b1e97009 100644 --- a/code/lib/react-dom-shim/src/react-16.tsx +++ b/code/lib/react-dom-shim/src/react-16.tsx @@ -2,12 +2,14 @@ import type { ReactElement } from 'react'; import * as ReactDOM from 'react-dom'; +import { preventActChecks } from './preventActChecks'; + export const renderElement = async (node: ReactElement, el: Element) => { return new Promise((resolve) => { - ReactDOM.render(node, el, () => resolve(null)); + preventActChecks(() => void ReactDOM.render(node, el, () => resolve(null))); }); }; export const unmountElement = (el: Element) => { - ReactDOM.unmountComponentAtNode(el); + preventActChecks(() => void ReactDOM.unmountComponentAtNode(el)); }; diff --git a/code/lib/react-dom-shim/src/react-18.tsx b/code/lib/react-dom-shim/src/react-18.tsx index 254fdfbdab7e..1d274b07af9a 100644 --- a/code/lib/react-dom-shim/src/react-18.tsx +++ b/code/lib/react-dom-shim/src/react-18.tsx @@ -1,8 +1,11 @@ +/* eslint-disable @typescript-eslint/no-unnecessary-type-constraint */ import type { FC, ReactElement } from 'react'; import * as React from 'react'; import type { Root as ReactRoot, RootOptions } from 'react-dom/client'; import * as ReactDOM from 'react-dom/client'; +import { preventActChecks } from './preventActChecks'; + // A map of all rendered React 18 nodes const nodes = new Map(); @@ -21,20 +24,33 @@ const WithCallback: FC<{ callback: () => void; children: ReactElement }> = ({ return children; }; +// pony-fill +if (typeof Promise.withResolvers === 'undefined') { + Promise.withResolvers = () => { + let resolve: PromiseWithResolvers['resolve'] = null!; + let reject: PromiseWithResolvers['reject'] = null!; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; + }; +} + export const renderElement = async (node: ReactElement, el: Element, rootOptions?: RootOptions) => { // Create Root Element conditionally for new React 18 Root Api const root = await getReactRoot(el, rootOptions); - return new Promise((resolve) => { - root.render( resolve(null)}>{node}); - }); + const { promise, resolve } = Promise.withResolvers(); + preventActChecks(() => root.render({node})); + return promise; }; export const unmountElement = (el: Element, shouldUseNewRootApi?: boolean) => { const root = nodes.get(el); if (root) { - root.unmount(); + preventActChecks(() => root.unmount()); nodes.delete(el); } }; diff --git a/code/package.json b/code/package.json index 78ec1fff6a05..ee64b5ccacba 100644 --- a/code/package.json +++ b/code/package.json @@ -48,6 +48,8 @@ "storybook:ui": "NODE_OPTIONS=\"--preserve-symlinks --preserve-symlinks-main\" ./lib/cli/bin/index.cjs dev --port 6006 --config-dir ./.storybook", "storybook:ui:build": "NODE_OPTIONS=\"--preserve-symlinks --preserve-symlinks-main\" ./lib/cli/bin/index.cjs build --config-dir ./.storybook --webpack-stats-json", "storybook:ui:chromatic": "../scripts/node_modules/.bin/chromatic --build-script-name storybook:ui:build --storybook-base-dir ./ --exit-zero-on-changes --exit-once-uploaded", + "storybook:vitest": "yarn test --project storybook-ui", + "storybook:vitest:inspect": "INSPECT=true yarn test --project storybook-ui", "task": "yarn --cwd ../scripts task", "test": "NODE_OPTIONS=--max_old_space_size=4096 vitest run", "test:watch": "NODE_OPTIONS=--max_old_space_size=4096 vitest watch" @@ -122,6 +124,7 @@ "@storybook/csf-plugin": "workspace:*", "@storybook/ember": "workspace:*", "@storybook/eslint-config-storybook": "^4.0.0", + "@storybook/experimental-addon-vitest": "workspace:*", "@storybook/global": "^5.0.0", "@storybook/html": "workspace:*", "@storybook/html-vite": "workspace:*", @@ -174,6 +177,8 @@ "@typescript-eslint/experimental-utils": "^5.62.0", "@typescript-eslint/parser": "^6.18.1", "@vitejs/plugin-react": "^3.0.1", + "@vitest/browser": "^2.0.5", + "@vitest/coverage-istanbul": "^2.0.5", "@vitest/coverage-v8": "^2.0.5", "create-storybook": "workspace:*", "cross-env": "^7.0.3", @@ -207,6 +212,7 @@ "typescript": "^5.4.3", "util": "^0.12.4", "vite": "^4.0.0", + "vite-plugin-inspect": "^0.8.5", "vitest": "^2.0.5", "wait-on": "^7.0.1" }, diff --git a/code/renderers/react/package.json b/code/renderers/react/package.json index 449842ea9f43..6f8bd4088a9f 100644 --- a/code/renderers/react/package.json +++ b/code/renderers/react/package.json @@ -116,7 +116,7 @@ "entries": [ "./src/index.ts", "./src/preset.ts", - "./src/entry-preview.ts", + "./src/entry-preview.tsx", "./src/entry-preview-docs.ts", "./src/entry-preview-rsc.tsx", "./src/playwright.ts" diff --git a/code/renderers/react/src/__test__/Button.stories.tsx b/code/renderers/react/src/__test__/Button.stories.tsx index 1dab871d9e2c..bde220fdf469 100644 --- a/code/renderers/react/src/__test__/Button.stories.tsx +++ b/code/renderers/react/src/__test__/Button.stories.tsx @@ -1,7 +1,7 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; -import { expect, fn, userEvent, within } from '@storybook/test'; -import { mocked } from '@storybook/test'; +import { expect, fn, mocked, userEvent, within } from '@storybook/test'; import type { HandlerFunction } from '@storybook/addon-actions'; import { action } from '@storybook/addon-actions'; @@ -89,6 +89,34 @@ export const CSF3ButtonWithRender: CSF3Story = { ), }; +export const HooksStory: CSF3Story = { + render: function Component() { + const [isClicked, setClicked] = useState(false); + return ( + <> + +
+ + + ); + }, + play: async ({ canvasElement, step }) => { + console.log('start of play function'); + const canvas = within(canvasElement); + await step('Step label', async () => { + const inputEl = canvas.getByTestId('input'); + const buttonEl = canvas.getByRole('button'); + await userEvent.click(buttonEl); + await userEvent.type(inputEl, 'Hello world!'); + + await expect(inputEl).toHaveValue('Hello world!'); + }); + console.log('end of play function'); + }, +}; + export const CSF3InputFieldFilled: CSF3Story = { render: () => { return ; @@ -185,3 +213,64 @@ export const WithActionArgType: CSF3Story<{ someActionArg: HandlerFunction }> = return
nothing
; }, }; + +export const Modal: CSF3Story = { + render: function Component() { + const [isModalOpen, setIsModalOpen] = useState(false); + const [modalContainer] = useState(() => { + const div = document.createElement('div'); + div.id = 'modal-root'; + return div; + }); + + useEffect(() => { + document.body.appendChild(modalContainer); + return () => { + document.body.removeChild(modalContainer); + }; + }, [modalContainer]); + + const handleOpenModal = () => setIsModalOpen(true); + const handleCloseModal = () => setIsModalOpen(false); + + const modalContent = isModalOpen + ? createPortal( +
+
+

This is a modal!

+
+ +
, + modalContainer + ) + : null; + + return ( + <> + + {modalContent} + + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const openModalButton = await 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-legacy.test.tsx.snap b/code/renderers/react/src/__test__/__snapshots__/portable-stories-legacy.test.tsx.snap index 832812f31781..b4753327aaf1 100644 --- a/code/renderers/react/src/__test__/__snapshots__/portable-stories-legacy.test.tsx.snap +++ b/code/renderers/react/src/__test__/__snapshots__/portable-stories-legacy.test.tsx.snap @@ -82,6 +82,21 @@ exports[`Legacy Portable Stories API > Renders CSF3Primary story 1`] = ` `; +exports[`Legacy Portable Stories API > Renders HooksStory story 1`] = ` + +
+ +
+ +
+ +`; + exports[`Legacy Portable Stories API > Renders LoaderStory story 1`] = `
@@ -101,6 +116,37 @@ exports[`Legacy Portable Stories API > Renders LoaderStory story 1`] = ` `; +exports[`Legacy Portable Stories API > Renders Modal story 1`] = ` + +
+ +
+ + +`; + exports[`Legacy Portable Stories API > Renders WithActionArg story 1`] = `
diff --git a/code/renderers/react/src/__test__/__snapshots__/portable-stories.test.tsx.snap b/code/renderers/react/src/__test__/__snapshots__/portable-stories.test.tsx.snap index e89beeda664d..3f00ff746281 100644 --- a/code/renderers/react/src/__test__/__snapshots__/portable-stories.test.tsx.snap +++ b/code/renderers/react/src/__test__/__snapshots__/portable-stories.test.tsx.snap @@ -82,6 +82,21 @@ exports[`Renders CSF3Primary story 1`] = ` `; +exports[`Renders HooksStory story 1`] = ` + +
+ +
+ +
+ +`; + exports[`Renders LoaderStory story 1`] = `
@@ -101,6 +116,37 @@ exports[`Renders LoaderStory story 1`] = ` `; +exports[`Renders Modal story 1`] = ` + +
+ +
+ + +`; + exports[`Renders MountInPlayFunction story 1`] = `
diff --git a/code/renderers/react/src/__test__/portable-stories-legacy.test.tsx b/code/renderers/react/src/__test__/portable-stories-legacy.test.tsx index 16e017e76c7f..3c7321cdfe63 100644 --- a/code/renderers/react/src/__test__/portable-stories-legacy.test.tsx +++ b/code/renderers/react/src/__test__/portable-stories-legacy.test.tsx @@ -196,6 +196,7 @@ describe('Legacy Portable Stories API', () => { const testCases = Object.values(composeStories(stories)).map( (Story) => [Story.storyName, Story] as [string, typeof Story] ); + it.each(testCases)('Renders %s story', async (_storyName, Story) => { cleanup(); @@ -207,7 +208,14 @@ describe('Legacy Portable Stories API', () => { const { baseElement } = await render(); + globalThis.IS_REACT_ACT_ENVIRONMENT = false; await Story.play?.(); + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + expect(baseElement).toMatchSnapshot(); }); }); + +declare const globalThis: { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}; diff --git a/code/renderers/react/src/__test__/portable-stories.test.tsx b/code/renderers/react/src/__test__/portable-stories.test.tsx index 0a73f98f8e67..0303c7873628 100644 --- a/code/renderers/react/src/__test__/portable-stories.test.tsx +++ b/code/renderers/react/src/__test__/portable-stories.test.tsx @@ -18,7 +18,7 @@ import { composeStories, composeStory, setProjectAnnotations } from '..'; import type { Button } from './Button'; import * as stories from './Button.stories'; -setProjectAnnotations([{ testingLibraryRender: render }]); +setProjectAnnotations([]); // example with composeStories, returns an object with all stories composed with args/decorators const { CSF3Primary, LoaderStory, MountInPlayFunction } = composeStories(stories); @@ -27,6 +27,10 @@ afterEach(() => { cleanup(); }); +declare const globalThis: { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}; + // example with composeStory, returns a single story composed with args/decorators const Secondary = composeStory(stories.CSF2Secondary, stories.default); describe('renders', () => { @@ -81,7 +85,6 @@ describe('projectAnnotations', () => { it('renders with default projectAnnotations', () => { setProjectAnnotations([ { - testingLibraryRender: render, parameters: { injected: true }, globalTypes: { locale: { defaultValue: 'en' }, @@ -148,6 +151,18 @@ describe('CSF3', () => { document.body.removeChild(div); }); + + it('renders with hooks', async () => { + // TODO find out why act is not working here + globalThis.IS_REACT_ACT_ENVIRONMENT = false; + const HooksStory = composeStory(stories.HooksStory, stories.default); + + await HooksStory.run(); + + const input = screen.getByTestId('input') as HTMLInputElement; + expect(input.value).toEqual('Hello world!'); + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + }); }); // common in addons that need to communicate between manager and preview @@ -188,6 +203,8 @@ const testCases = Object.values(composeStories(stories)).map( ); it.each(testCases)('Renders %s story', async (_storyName, Story) => { if (_storyName === 'CSF2StoryWithLocale') return; + globalThis.IS_REACT_ACT_ENVIRONMENT = false; await Story.run(); + globalThis.IS_REACT_ACT_ENVIRONMENT = true; expect(document.body).toMatchSnapshot(); }); diff --git a/code/renderers/react/src/entry-preview-rsc.tsx b/code/renderers/react/src/entry-preview-rsc.tsx index d3c0b615ab45..fbed66af819e 100644 --- a/code/renderers/react/src/entry-preview-rsc.tsx +++ b/code/renderers/react/src/entry-preview-rsc.tsx @@ -1,32 +1,3 @@ -import * as React from 'react'; - -import type { Addon_DecoratorFunction } from 'storybook/internal/types'; - -import semver from 'semver'; - -import type { StoryContext } from './types'; - -export const ServerComponentDecorator = ( - Story: React.FC, - { parameters }: StoryContext -): React.ReactNode => { - if (!parameters?.react?.rsc) return ; - - const major = semver.major(React.version); - const minor = semver.minor(React.version); - if (major < 18 || (major === 18 && minor < 3)) { - throw new Error('React Server Components require React >= 18.3'); - } - - return ( - - - - ); -}; - -export const decorators: Addon_DecoratorFunction[] = [ServerComponentDecorator]; - export const parameters = { react: { rsc: true, diff --git a/code/renderers/react/src/entry-preview.ts b/code/renderers/react/src/entry-preview.ts deleted file mode 100644 index 62fada95f31a..000000000000 --- a/code/renderers/react/src/entry-preview.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const parameters: {} = { renderer: 'react' }; -export { render } from './render'; -export { renderToCanvas } from './renderToCanvas'; -export { mount } from './mount'; diff --git a/code/renderers/react/src/entry-preview.tsx b/code/renderers/react/src/entry-preview.tsx new file mode 100644 index 000000000000..a9cb4a936a1b --- /dev/null +++ b/code/renderers/react/src/entry-preview.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; + +import semver from 'semver'; + +import type { Decorator } from './public-types'; + +export const parameters = { renderer: 'react' }; +export { render } from './render'; +export { renderToCanvas } from './renderToCanvas'; +export { mount } from './mount'; + +export const decorators: Decorator[] = [ + (Story, context) => { + if (!context.parameters?.react?.rsc) return ; + + const major = semver.major(React.version); + const minor = semver.minor(React.version); + if (major < 18 || (major === 18 && minor < 3)) { + throw new Error('React Server Components require React >= 18.3'); + } + + return ( + + + + ); + }, +]; diff --git a/code/renderers/react/src/portable-stories.tsx b/code/renderers/react/src/portable-stories.tsx index c5bcb9cc28df..95c8a63ddfae 100644 --- a/code/renderers/react/src/portable-stories.tsx +++ b/code/renderers/react/src/portable-stories.tsx @@ -4,12 +4,13 @@ import { composeStories as originalComposeStories, composeStory as originalComposeStory, setProjectAnnotations as originalSetProjectAnnotations, + setDefaultProjectAnnotations, } from 'storybook/internal/preview-api'; -import { TestingLibraryMustBeConfiguredError } from 'storybook/internal/preview-errors'; import type { Args, ComposedStoryFn, NamedOrDefaultProjectAnnotations, + NormalizedProjectAnnotations, ProjectAnnotations, Store_CSFExports, StoriesWithPartialProps, @@ -37,10 +38,13 @@ import type { ReactRenderer } from './types'; */ export function setProjectAnnotations( projectAnnotations: - | NamedOrDefaultProjectAnnotations - | NamedOrDefaultProjectAnnotations[] -): ProjectAnnotations { - return originalSetProjectAnnotations(projectAnnotations); + | NamedOrDefaultProjectAnnotations + | NamedOrDefaultProjectAnnotations[] +): NormalizedProjectAnnotations { + setDefaultProjectAnnotations(INTERNAL_DEFAULT_PROJECT_ANNOTATIONS); + return originalSetProjectAnnotations( + projectAnnotations + ) as NormalizedProjectAnnotations; } // This will not be necessary once we have auto preset loading @@ -48,9 +52,7 @@ export const INTERNAL_DEFAULT_PROJECT_ANNOTATIONS: ProjectAnnotations { if (renderContext.storyContext.testingLibraryRender == null) { - throw new TestingLibraryMustBeConfiguredError(); - // Enable for 8.3 - // return reactProjectAnnotations.renderToCanvas(renderContext, canvasElement); + return reactProjectAnnotations.renderToCanvas(renderContext, canvasElement); } const { storyContext: { context, unboundStoryFn: Story, testingLibraryRender: render }, diff --git a/code/renderers/react/template/stories/errors.stories.tsx b/code/renderers/react/template/stories/errors.stories.tsx index 8db00f803d68..2c83be0cfeb9 100644 --- a/code/renderers/react/template/stories/errors.stories.tsx +++ b/code/renderers/react/template/stories/errors.stories.tsx @@ -8,7 +8,7 @@ export default { parameters: { chromatic: { disable: true }, }, - tags: ['!test'], + tags: ['!test', '!vitest'], }; export const RenderThrows = { diff --git a/code/renderers/svelte/src/__test__/composeStories/__snapshots__/portable-stories.test.ts.snap b/code/renderers/svelte/src/__test__/composeStories/__snapshots__/portable-stories.test.ts.snap index 4cef8820fbc5..6042c29439c9 100644 --- a/code/renderers/svelte/src/__test__/composeStories/__snapshots__/portable-stories.test.ts.snap +++ b/code/renderers/svelte/src/__test__/composeStories/__snapshots__/portable-stories.test.ts.snap @@ -11,6 +11,9 @@ exports[`Renders CSF2Secondary story 1`] = ` label coming from story args! + + +
@@ -36,6 +39,9 @@ exports[`Renders CSF2StoryWithParamsAndDecorator story 1`] = `
+ + +
@@ -52,6 +58,9 @@ exports[`Renders CSF3Button story 1`] = ` foo + + +
@@ -77,6 +86,9 @@ exports[`Renders CSF3ButtonWithRender story 1`] = ` + + + @@ -90,6 +102,9 @@ exports[`Renders CSF3InputFieldFilled story 1`] = ` formaction="http://localhost:3000/" formmethod="" /> + + + @@ -106,6 +121,9 @@ exports[`Renders CSF3Primary story 1`] = ` foo + + + @@ -127,6 +145,9 @@ exports[`Renders LoaderStory story 1`] = ` mockFn return value + + + @@ -152,6 +173,9 @@ exports[`Renders NewStory story 1`] = ` + + + diff --git a/code/renderers/svelte/src/__test__/composeStories/portable-stories.test.ts b/code/renderers/svelte/src/__test__/composeStories/portable-stories.test.ts index 12e477349fd8..21eee9f0582c 100644 --- a/code/renderers/svelte/src/__test__/composeStories/portable-stories.test.ts +++ b/code/renderers/svelte/src/__test__/composeStories/portable-stories.test.ts @@ -12,7 +12,7 @@ import * as stories from './Button.stories'; // import type Button from './Button.svelte'; import type Button from './Button.svelte'; -setProjectAnnotations({ testingLibraryRender: render }); +setProjectAnnotations([]); // example with composeStories, returns an object with all stories composed with args/decorators const { CSF3Primary, LoaderStory } = composeStories(stories); @@ -83,7 +83,6 @@ describe('projectAnnotations', () => { globalTypes: { locale: { defaultValue: 'en' }, }, - testingLibraryRender: render, }, ]); const WithEnglishText = composeStory(stories.CSF2StoryWithLocale, stories.default); diff --git a/code/renderers/svelte/src/portable-stories.ts b/code/renderers/svelte/src/portable-stories.ts index 417c1ff258b4..bdbf38b60850 100644 --- a/code/renderers/svelte/src/portable-stories.ts +++ b/code/renderers/svelte/src/portable-stories.ts @@ -2,12 +2,13 @@ import { composeStories as originalComposeStories, composeStory as originalComposeStory, setProjectAnnotations as originalSetProjectAnnotations, + setDefaultProjectAnnotations, } from 'storybook/internal/preview-api'; -import { TestingLibraryMustBeConfiguredError } from 'storybook/internal/preview-errors'; import type { Args, ComposedStoryFn, NamedOrDefaultProjectAnnotations, + NormalizedProjectAnnotations, ProjectAnnotations, Store_CSFExports, StoriesWithPartialProps, @@ -55,10 +56,13 @@ type MapToComposed = { */ export function setProjectAnnotations( projectAnnotations: - | NamedOrDefaultProjectAnnotations - | NamedOrDefaultProjectAnnotations[] -): ProjectAnnotations { - return originalSetProjectAnnotations(projectAnnotations); + | NamedOrDefaultProjectAnnotations + | NamedOrDefaultProjectAnnotations[] +): NormalizedProjectAnnotations { + setDefaultProjectAnnotations(INTERNAL_DEFAULT_PROJECT_ANNOTATIONS); + return originalSetProjectAnnotations( + projectAnnotations + ) as NormalizedProjectAnnotations; } // This will not be necessary once we have auto preset loading @@ -66,9 +70,7 @@ export const INTERNAL_DEFAULT_PROJECT_ANNOTATIONS: ProjectAnnotations { if (renderContext.storyContext.testingLibraryRender == null) { - throw new TestingLibraryMustBeConfiguredError(); - // Enable for 8.3 - // return svelteProjectAnnotations.renderToCanvas(renderContext, canvasElement); + return svelteProjectAnnotations.renderToCanvas(renderContext, canvasElement); } const { storyFn, diff --git a/code/renderers/svelte/src/render.ts b/code/renderers/svelte/src/render.ts index c43e735f989b..84d112c1db73 100644 --- a/code/renderers/svelte/src/render.ts +++ b/code/renderers/svelte/src/render.ts @@ -79,7 +79,6 @@ function renderToCanvasV4( const mountedComponent = new PreviewRender({ target: canvasElement, props: { - svelteVersion: 4, storyFn, storyContext, name, @@ -151,7 +150,6 @@ function renderToCanvasV5( name, title, showError, - svelteVersion: 5, }); const mountedComponent = svelte.mount(PreviewRender, { target: canvasElement, diff --git a/code/renderers/svelte/template/stories/args.stories.js b/code/renderers/svelte/template/stories/args.stories.js index 4d276791a2cd..8f12bafaa5b8 100644 --- a/code/renderers/svelte/template/stories/args.stories.js +++ b/code/renderers/svelte/template/stories/args.stories.js @@ -11,6 +11,7 @@ import ButtonView from './views/ButtonJavaScript.svelte'; export default { component: ButtonView, + tags: ['!vitest'], }; export const RemountOnResetStoryArgs = { diff --git a/code/renderers/vue3/package.json b/code/renderers/vue3/package.json index 3024ac8b7af6..986fd0257f55 100644 --- a/code/renderers/vue3/package.json +++ b/code/renderers/vue3/package.json @@ -22,9 +22,9 @@ "exports": { ".": { "types": "./dist/index.d.ts", - "node": "./dist/index.js", "import": "./dist/index.mjs", - "require": "./dist/index.js" + "require": "./dist/index.js", + "node": "./dist/index.js" }, "./experimental-playwright": { "types": "./dist/playwright.d.ts", diff --git a/code/renderers/vue3/src/__tests__/composeStories/__snapshots__/portable-stories.test.ts.snap b/code/renderers/vue3/src/__tests__/composeStories/__snapshots__/portable-stories.test.ts.snap index bb968e20d142..0afcb1c7efbb 100644 --- a/code/renderers/vue3/src/__tests__/composeStories/__snapshots__/portable-stories.test.ts.snap +++ b/code/renderers/vue3/src/__tests__/composeStories/__snapshots__/portable-stories.test.ts.snap @@ -2,7 +2,9 @@ exports[`Renders CSF2Secondary story 1`] = ` -
+
- + return ( + <> +

locale: {locale}

+ + + ); }; CSF2StoryWithLocale.storyName = 'WithLocale'; @@ -83,33 +83,6 @@ export const CSF3ButtonWithRender: CSF3Story = { ), }; -export const CSF3InputFieldFilled: CSF3Story = { - render: function Component() { - const [isClicked, setClicked] = useState(false); - return ( - <> - -
- - - ); - }, - play: async ({ canvasElement, step }) => { - console.log('start of play function'); - const canvas = within(canvasElement); - await step('Step label', async () => { - const inputEl = canvas.getByTestId('input'); - const buttonEl = canvas.getByRole('button'); - await userEvent.click(buttonEl); - await userEvent.type(inputEl, 'Hello world!'); - await expect(inputEl).toHaveValue('Hello world!'); - }); - console.log('end of play function'); - }, -}; - const mockFn = fn(); export const WithLoader: CSF3Story<{ mockFn: (val: string) => string }> = { args: { @@ -136,64 +109,3 @@ export const WithLoader: CSF3Story<{ mockFn: (val: string) => string }> = { expect(mockFn).toHaveBeenCalledWith('render'); }, }; - -export const Modal: CSF3Story = { - render: function Component() { - const [isModalOpen, setIsModalOpen] = useState(false); - const [modalContainer] = useState(() => { - const div = document.createElement('div'); - div.id = 'modal-root'; - return div; - }); - - useEffect(() => { - document.body.appendChild(modalContainer); - return () => { - document.body.removeChild(modalContainer); - }; - }, [modalContainer]); - - const handleOpenModal = () => setIsModalOpen(true); - const handleCloseModal = () => setIsModalOpen(false); - - const modalContent = isModalOpen - ? createPortal( -
-
-

This is a modal!

-
- -
, - modalContainer - ) - : null; - - return ( - <> - - {modalContent} - - ); - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const openModalButton = await canvas.getByRole('button', { name: /open modal/i }); - await userEvent.click(openModalButton); - await expect(screen.getByRole('dialog')).toBeInTheDocument(); - }, -}; diff --git a/test-storybooks/portable-stories-kitchen-sink/react/stories/Button.test.tsx b/test-storybooks/portable-stories-kitchen-sink/react/stories/Button.test.tsx index 314d3a0d031d..1b4839207c88 100644 --- a/test-storybooks/portable-stories-kitchen-sink/react/stories/Button.test.tsx +++ b/test-storybooks/portable-stories-kitchen-sink/react/stories/Button.test.tsx @@ -63,7 +63,7 @@ describe('projectAnnotations', () => { }); it('renders with custom projectAnnotations via setProjectAnnotations', () => { - setProjectAnnotations([{ parameters: { injected: true }, testingLibraryRender: render }]); + setProjectAnnotations([{ parameters: { injected: true } }]); const Story = composeStory(stories.CSF2StoryWithLocale, stories.default); expect(Story.parameters?.injected).toBe(true); }); @@ -88,15 +88,6 @@ describe('CSF3', () => { render(); expect(screen.getByTestId('custom-render')).not.toBeNull(); }); - - it('renders with play function', async () => { - const CSF3InputFieldFilled = composeStory(stories.CSF3InputFieldFilled, stories.default); - - await CSF3InputFieldFilled.run(); - - const input = screen.getByTestId('input') as HTMLInputElement; - expect(input.value).toEqual('Hello world!'); - }); }); // common in addons that need to communicate between manager and preview diff --git a/test-storybooks/portable-stories-kitchen-sink/react/stories/__snapshots__/Button.test.tsx.snap b/test-storybooks/portable-stories-kitchen-sink/react/stories/__snapshots__/Button.test.tsx.snap index 4a62c4640429..784d1397f54a 100644 --- a/test-storybooks/portable-stories-kitchen-sink/react/stories/__snapshots__/Button.test.tsx.snap +++ b/test-storybooks/portable-stories-kitchen-sink/react/stories/__snapshots__/Button.test.tsx.snap @@ -106,27 +106,6 @@ exports[`Renders CSF3ButtonWithRender story 1`] = ` `; -exports[`Renders CSF3InputFieldFilled story 1`] = ` - -
-
- Global Decorator -
- -
- -
-
- -`; - exports[`Renders CSF3Primary story 1`] = `
@@ -146,43 +125,6 @@ exports[`Renders CSF3Primary story 1`] = ` `; -exports[`Renders Modal story 1`] = ` - -
-
- Global Decorator -
- -
-
- - -`; - exports[`Renders WithLoader story 1`] = `
diff --git a/test-storybooks/portable-stories-kitchen-sink/svelte/package.json b/test-storybooks/portable-stories-kitchen-sink/svelte/package.json index 5e77fe5fc967..9128cf58d986 100644 --- a/test-storybooks/portable-stories-kitchen-sink/svelte/package.json +++ b/test-storybooks/portable-stories-kitchen-sink/svelte/package.json @@ -101,4 +101,4 @@ "vite": "^5.1.4", "vitest": "^1.6.0" } -} \ No newline at end of file +} diff --git a/test-storybooks/portable-stories-kitchen-sink/svelte/stories/Button.test.ts b/test-storybooks/portable-stories-kitchen-sink/svelte/stories/Button.test.ts index 094d2a592326..fd694b23d15c 100644 --- a/test-storybooks/portable-stories-kitchen-sink/svelte/stories/Button.test.ts +++ b/test-storybooks/portable-stories-kitchen-sink/svelte/stories/Button.test.ts @@ -6,7 +6,7 @@ import * as stories from './Button.stories'; // import type Button from './Button.svelte'; import { composeStories, composeStory, setProjectAnnotations } from '@storybook/svelte'; -setProjectAnnotations({ testingLibraryRender: render }); +setProjectAnnotations([]); // example with composeStories, returns an object with all stories composed with args/decorators const { CSF3Primary, LoaderStory } = composeStories(stories); @@ -71,7 +71,6 @@ describe('projectAnnotations', () => { globalTypes: { locale: { defaultValue: 'en' }, }, - testingLibraryRender: render, }, ]); const WithEnglishText = composeStory(stories.CSF2StoryWithLocale, stories.default); diff --git a/test-storybooks/portable-stories-kitchen-sink/svelte/stories/__snapshots__/Button.test.ts.snap b/test-storybooks/portable-stories-kitchen-sink/svelte/stories/__snapshots__/Button.test.ts.snap index 976716d156c4..80fb1d74c3f4 100644 --- a/test-storybooks/portable-stories-kitchen-sink/svelte/stories/__snapshots__/Button.test.ts.snap +++ b/test-storybooks/portable-stories-kitchen-sink/svelte/stories/__snapshots__/Button.test.ts.snap @@ -11,6 +11,8 @@ exports[`Renders CSF2Secondary story 1`] = ` label coming from story args! + +
`; @@ -33,6 +35,8 @@ exports[`Renders CSF2StoryWithParamsAndDecorator story 1`] = `
+ +
`; @@ -48,6 +52,8 @@ exports[`Renders CSF3Button story 1`] = ` foo + +
`; @@ -71,6 +77,8 @@ exports[`Renders CSF3ButtonWithRender story 1`] = ` + + `; @@ -81,6 +89,8 @@ exports[`Renders CSF3InputFieldFilled story 1`] = ` + + `; @@ -96,6 +106,8 @@ exports[`Renders CSF3Primary story 1`] = ` foo + + `; @@ -116,6 +128,8 @@ exports[`Renders LoaderStory story 1`] = ` mockFn return value + + `; @@ -138,6 +152,8 @@ exports[`Renders NewStory story 1`] = ` + + `;