mirror of
https://github.com/storybookjs/storybook.git
synced 2025-04-09 00:19:13 +08:00
Merge pull request #28768 from storybookjs/vitest-integration
Addon Vitest: Add experimental vitest integration
This commit is contained in:
commit
bdea6d23c1
@ -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
|
||||
|
4
.github/workflows/tests-unit.yml
vendored
4
.github/workflows/tests-unit.yml
vendored
@ -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
|
||||
|
39
MIGRATION.md
39
MIGRATION.md
@ -1,6 +1,7 @@
|
||||
<h1>Migration</h1>
|
||||
|
||||
- [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`.
|
||||
|
3
code/.gitignore
vendored
3
code/.gitignore
vendored
@ -1 +1,2 @@
|
||||
.nx/cache
|
||||
.nx/cache
|
||||
.vite-inspect
|
@ -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',
|
||||
],
|
||||
|
@ -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
|
||||
|
38
code/.storybook/storybook.setup.ts
Normal file
38
code/.storybook/storybook.setup.ts
Normal file
@ -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);
|
56
code/.storybook/vitest.config.ts
Normal file
56
code/.storybook/vitest.config.ts
Normal file
@ -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',
|
||||
},
|
||||
})
|
||||
);
|
@ -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",
|
||||
|
@ -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"
|
||||
|
@ -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"
|
||||
},
|
||||
|
@ -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 } },
|
||||
};
|
||||
|
@ -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 };
|
||||
|
@ -14,7 +14,7 @@ export default {
|
||||
actions: { argTypesRegex: '^on[A-Z].*' },
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
tags: ['!test'],
|
||||
tags: ['!test', '!vitest'],
|
||||
};
|
||||
|
||||
export const Default = {
|
||||
|
@ -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",
|
||||
|
@ -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"
|
||||
|
@ -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",
|
||||
|
@ -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"
|
||||
|
@ -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": {
|
||||
|
@ -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"
|
||||
|
@ -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 = {
|
||||
|
3
code/addons/vitest/README.md
Normal file
3
code/addons/vitest/README.md
Normal file
@ -0,0 +1,3 @@
|
||||
# Storybook Addon Vitest (Experimental)
|
||||
|
||||
Addon to integrate Vitest test results with Storybook.
|
104
code/addons/vitest/package.json
Normal file
104
code/addons/vitest/package.json
Normal file
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
1
code/addons/vitest/postinstall.cjs
Normal file
1
code/addons/vitest/postinstall.cjs
Normal file
@ -0,0 +1 @@
|
||||
module.exports = require('./dist/postinstall.js');
|
8
code/addons/vitest/preset.cjs
Normal file
8
code/addons/vitest/preset.cjs
Normal file
@ -0,0 +1,8 @@
|
||||
function managerEntries(entry = []) {
|
||||
return [...entry, require.resolve('./dist/manager.js')];
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
managerEntries,
|
||||
...require('./dist/preset'),
|
||||
};
|
8
code/addons/vitest/project.json
Normal file
8
code/addons/vitest/project.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "vitest",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"projectType": "library",
|
||||
"targets": {
|
||||
"build": {}
|
||||
}
|
||||
}
|
1
code/addons/vitest/src/constants.ts
Normal file
1
code/addons/vitest/src/constants.ts
Normal file
@ -0,0 +1 @@
|
||||
export const ADDON_ID = 'storybook/vitest';
|
2
code/addons/vitest/src/index.ts
Normal file
2
code/addons/vitest/src/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
// make it work with --isolatedModules
|
||||
export default {};
|
5
code/addons/vitest/src/manager.tsx
Normal file
5
code/addons/vitest/src/manager.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { type API, addons } from 'storybook/internal/manager-api';
|
||||
|
||||
import { ADDON_ID } from './constants';
|
||||
|
||||
addons.register(ADDON_ID, () => {});
|
69
code/addons/vitest/src/plugin/global-setup.ts
Normal file
69
code/addons/vitest/src/plugin/global-setup.ts
Normal file
@ -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<boolean> => {
|
||||
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);
|
||||
}
|
||||
};
|
128
code/addons/vitest/src/plugin/index.ts
Normal file
128
code/addons/vitest/src/plugin/index.ts
Normal file
@ -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;
|
40
code/addons/vitest/src/plugin/setup-file.ts
Normal file
40
code/addons/vitest/src/plugin/setup-file.ts
Normal file
@ -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);
|
||||
});
|
35
code/addons/vitest/src/plugin/test-utils.ts
Normal file
35
code/addons/vitest/src/plugin/test-utils.ts
Normal file
@ -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<UserOptions['tags']>;
|
||||
|
||||
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();
|
||||
};
|
||||
};
|
33
code/addons/vitest/src/plugin/types.ts
Normal file
33
code/addons/vitest/src/plugin/types.ts
Normal file
@ -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<UserOptions> & {
|
||||
debug: boolean;
|
||||
tags: Required<UserOptions['tags']>;
|
||||
};
|
38
code/addons/vitest/src/plugin/viewports.ts
Normal file
38
code/addons/vitest/src/plugin/viewports.ts
Normal file
@ -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;
|
||||
};
|
3
code/addons/vitest/src/postinstall.ts
Normal file
3
code/addons/vitest/src/postinstall.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export default async function postinstall(context: any) {
|
||||
console.log('[addon-vitest] postinstall with', context);
|
||||
}
|
7
code/addons/vitest/src/preset.ts
Normal file
7
code/addons/vitest/src/preset.ts
Normal file
@ -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;
|
||||
};
|
10
code/addons/vitest/tsconfig.json
Normal file
10
code/addons/vitest/tsconfig.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "../../../",
|
||||
"module": "Preserve",
|
||||
"moduleResolution": "Bundler",
|
||||
"types": ["vitest"]
|
||||
},
|
||||
"include": ["src/**/*", "./typings.d.ts"]
|
||||
}
|
7
code/addons/vitest/typings.d.ts
vendored
Normal file
7
code/addons/vitest/typings.d.ts
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
interface ImportMetaEnv {
|
||||
__STORYBOOK_URL__?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
10
code/addons/vitest/vitest.config.ts
Normal file
10
code/addons/vitest/vitest.config.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { defineConfig, mergeConfig } from 'vitest/config';
|
||||
|
||||
import { vitestCommonConfig } from '../../vitest.workspace';
|
||||
|
||||
export default mergeConfig(
|
||||
vitestCommonConfig,
|
||||
defineConfig({
|
||||
// Add custom config here
|
||||
})
|
||||
);
|
@ -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",
|
||||
|
@ -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 };
|
||||
|
@ -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<GetStoryIdOptions, 'exportedStoryName'>) {
|
||||
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];
|
||||
}
|
@ -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',
|
||||
|
@ -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<LeftProps>(
|
||||
|
||||
export interface ItemProps {
|
||||
disabled?: boolean;
|
||||
href?: string;
|
||||
onClick?: (event: SyntheticEvent, ...args: any[]) => any;
|
||||
}
|
||||
|
||||
const Item = styled.a<ItemProps>(
|
||||
const Item = styled.div<ItemProps>(
|
||||
({ 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<ItemProps>(
|
||||
'& > * + *': {
|
||||
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 (
|
||||
<Item {...commonProps} {...rest} {...itemProps}>
|
||||
{icon && <Left {...commonProps}>{icon}</Left>}
|
||||
{title || center ? (
|
||||
<Center isIndented={!!(!icon && isIndented)}>
|
||||
{title && (
|
||||
<Title {...commonProps} loading={loading}>
|
||||
{title}
|
||||
</Title>
|
||||
)}
|
||||
{center && <CenterText {...commonProps}>{center}</CenterText>}
|
||||
</Center>
|
||||
) : null}
|
||||
{right && <Right {...commonProps}>{right}</Right>}
|
||||
<Item {...rest} {...commonProps} {...itemProps}>
|
||||
<>
|
||||
{icon && <Left {...commonProps}>{icon}</Left>}
|
||||
{title || center ? (
|
||||
<Center isIndented={!!(!icon && isIndented)}>
|
||||
{title && (
|
||||
<Title {...commonProps} loading={loading}>
|
||||
{title}
|
||||
</Title>
|
||||
)}
|
||||
{center && <CenterText {...commonProps}>{center}</CenterText>}
|
||||
</Center>
|
||||
) : null}
|
||||
{right && <Right {...commonProps}>{right}</Right>}
|
||||
</>
|
||||
</Item>
|
||||
);
|
||||
};
|
||||
|
@ -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<PropsWithChildren<StoryLinkWrapperProps>> = ({
|
||||
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: <LinkIcon />,
|
||||
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: <LinkIcon />,
|
||||
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: <LinkIcon />,
|
||||
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: <img src={ellipseUrl} width="16" height="16" alt="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: <img src={ellipseUrl} width="16" height="16" alt="ellipse" />,
|
||||
href: 'http://google.com',
|
||||
onClick: onLinkClick,
|
||||
},
|
||||
],
|
||||
LinkWrapper: StoryLinkWrapper,
|
||||
},
|
||||
} satisfies Story;
|
||||
|
||||
@ -193,6 +178,7 @@ export const WithCustomIcon = {
|
||||
right: <img src={ellipseUrl} width="16" height="16" alt="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: <img src={ellipseUrl} width="16" height="16" alt="ellipse" />,
|
||||
href: 'http://google.com',
|
||||
onClick: onLinkClick,
|
||||
},
|
||||
],
|
||||
LinkWrapper: StoryLinkWrapper,
|
||||
},
|
||||
} satisfies Story;
|
||||
|
@ -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<ListItemProps, 'onClick'> {
|
||||
id: string;
|
||||
isGatsby?: boolean;
|
||||
onClick?: (event: SyntheticEvent, item: ListItemProps) => void;
|
||||
onClick?: (
|
||||
event: SyntheticEvent,
|
||||
item: Pick<ListItemProps, 'id' | 'active' | 'disabled' | 'title'>
|
||||
) => 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 Item = ({ id, onClick, ...rest }: ItemProps) => {
|
||||
const { active, disabled, title } = rest;
|
||||
|
||||
const handleClick = useCallback(
|
||||
(event: SyntheticEvent) => onClick?.(event, { id, active, disabled, title }),
|
||||
[onClick, id, active, disabled, title]
|
||||
);
|
||||
|
||||
const hasOnClick = !!onClickFromProps;
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
title={title}
|
||||
active={active}
|
||||
href={href}
|
||||
id={`list-item-${id}`}
|
||||
LinkWrapper={LinkWrapper}
|
||||
isIndented={isIndented}
|
||||
{...rest}
|
||||
{...(hasOnClick ? { onClick } : {})}
|
||||
/>
|
||||
);
|
||||
return <ListItem id={`list-item-${id}`} {...rest} {...(onClick && { onClick: handleClick })} />;
|
||||
};
|
||||
|
||||
export interface TooltipLinkListProps {
|
||||
export interface TooltipLinkListProps extends ComponentProps<typeof List> {
|
||||
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 (
|
||||
<List>
|
||||
{links.map(({ isGatsby, ...p }) => (
|
||||
// @ts-expect-error (non strict)
|
||||
<Item key={p.id} LinkWrapper={isGatsby ? LinkWrapper : null} isIndented={hasIcon} {...p} />
|
||||
<List {...props}>
|
||||
{links.map((link) => (
|
||||
<Item key={link.id} isIndented={isIndented} LinkWrapper={LinkWrapper} {...link} />
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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<typeof import('@storybook/core/common')>();
|
||||
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<FileComponentSearchRequestPayload>);
|
||||
|
||||
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,59 +96,57 @@ 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' };
|
||||
it(
|
||||
'should emit search result event with an empty search result',
|
||||
{ timeout: 10000 },
|
||||
async () => {
|
||||
const mockOptions = {};
|
||||
const data = { searchQuery: 'no-file-for-search-query' };
|
||||
|
||||
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, {
|
||||
id: data.searchQuery,
|
||||
payload: {},
|
||||
} satisfies RequestData<FileComponentSearchRequestPayload>);
|
||||
mockChannel.addListener(FILE_COMPONENT_SEARCH_RESPONSE, searchResultChannelListener);
|
||||
mockChannel.emit(FILE_COMPONENT_SEARCH_REQUEST, {
|
||||
id: data.searchQuery,
|
||||
payload: {},
|
||||
} satisfies RequestData<FileComponentSearchRequestPayload>);
|
||||
|
||||
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,
|
||||
payload: {
|
||||
files: [],
|
||||
},
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
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<FileComponentSearchRequestPayload>);
|
||||
|
||||
mocks.searchFiles.mockRejectedValue(new Error('ENOENT: no such file or directory'));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(searchResultChannelListener).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
vi.mocked(searchFiles).mockRejectedValue(new Error('ENOENT: no such file or directory'));
|
||||
|
||||
await vi.waitFor(() => expect(searchResultChannelListener).toHaveBeenCalled());
|
||||
|
||||
expect(searchResultChannelListener).toHaveBeenCalledWith({
|
||||
id: data.searchQuery,
|
||||
error:
|
||||
|
@ -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<CsfOptions> = { 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;"
|
||||
`);
|
||||
});
|
||||
|
||||
|
@ -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,12 +188,12 @@ export interface StaticStory extends Pick<StoryAnnotations, 'name' | 'parameters
|
||||
export class CsfFile {
|
||||
_ast: t.File;
|
||||
|
||||
_fileName: string;
|
||||
_file: BabelFile;
|
||||
|
||||
_options: CsfOptions;
|
||||
|
||||
_rawComponentPath?: string;
|
||||
|
||||
_makeTitle: (title: string) => string;
|
||||
|
||||
_meta?: StaticMeta;
|
||||
|
||||
_stories: Record<string, StaticStory> = {};
|
||||
@ -187,7 +206,9 @@ export class CsfFile {
|
||||
|
||||
_metaNode: t.Expression | undefined;
|
||||
|
||||
_storyStatements: Record<string, t.ExportNamedDeclaration> = {};
|
||||
_metaVariableName: string | undefined;
|
||||
|
||||
_storyStatements: Record<string, t.ExportNamedDeclaration | t.Expression> = {};
|
||||
|
||||
_storyAnnotations: Record<string, Record<string, t.Node>> = {};
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
@ -3,3 +3,4 @@ export * from './ConfigFile';
|
||||
export * from './getStorySortParameter';
|
||||
export * from './enrichCsf';
|
||||
export * from './babelParse';
|
||||
export { vitestTransform } from './vitest-plugin/transformer';
|
||||
|
351
code/core/src/csf-tools/vitest-plugin/transformer.test.ts
Normal file
351
code/core/src/csf-tools/vitest-plugin/transformer.test.ts
Normal file
@ -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<typeof import('@storybook/core/common')>();
|
||||
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".
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
170
code/core/src/csf-tools/vitest-plugin/transformer.ts
Normal file
170
code/core/src/csf-tools/vitest-plugin/transformer.ts
Normal file
@ -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);
|
||||
}
|
@ -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<SubAPI, SubState> = ({
|
||||
|
||||
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<SubAPI, SubState> = ({
|
||||
Object.entries(refs).forEach(([refId, { internal_index, ...ref }]) => {
|
||||
fullAPI.setRef(refId, { ...ref, storyIndex: internal_index }, true);
|
||||
});
|
||||
|
||||
provider.channel?.emit(SET_FILTER, { id });
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -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,
|
||||
},
|
||||
};
|
60
code/core/src/manager/components/sidebar/FilterToggle.tsx
Normal file
60
code/core/src/manager/components/sidebar/FilterToggle.tsx
Normal file
@ -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<typeof Badge>['status'];
|
||||
}
|
||||
|
||||
export const FilterToggle = ({
|
||||
active,
|
||||
count,
|
||||
label,
|
||||
status,
|
||||
...props
|
||||
}: FilterToggleProps & Omit<ComponentProps<typeof Button>, 'status'>) => {
|
||||
return (
|
||||
<Button active={active} {...props}>
|
||||
<Badge status={status} data-badge={active} data-status={status}>
|
||||
{count}
|
||||
</Badge>
|
||||
<Label>{`${label}${count === 1 ? '' : 's'}`}</Label>
|
||||
</Button>
|
||||
);
|
||||
};
|
@ -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"
|
||||
/>
|
||||
</symbol>
|
||||
<symbol id={SUCCESS_ID}>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M10.854 4.146a.5.5 0 010 .708l-5 5a.5.5 0 01-.708 0l-2-2a.5.5 0 11.708-.708L5.5 8.793l4.646-4.647a.5.5 0 01.708 0z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</symbol>
|
||||
<symbol id={ERROR_ID}>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M7 4a3 3 0 100 6 3 3 0 000-6zM3 7a4 4 0 118 0 4 4 0 01-8 0z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</symbol>
|
||||
<symbol id={WARNING_ID}>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M7.206 3.044a.498.498 0 01.23.212l3.492 5.985a.494.494 0 01.006.507.497.497 0 01-.443.252H3.51a.499.499 0 01-.437-.76l3.492-5.984a.497.497 0 01.642-.212zM7 4.492L4.37 9h5.26L7 4.492z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</symbol>
|
||||
<symbol id={DOT_ID}>
|
||||
<circle cx="3" cy="3" r="3" fill="currentColor" />
|
||||
</symbol>
|
||||
</Svg>
|
||||
);
|
||||
};
|
||||
|
||||
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 <use xlinkHref={`#${GROUP_ID}`} />;
|
||||
if (type === 'component') return <use xlinkHref={`#${COMPONENT_ID}`} />;
|
||||
if (type === 'document') return <use xlinkHref={`#${DOCUMENT_ID}`} />;
|
||||
if (type === 'story') return <use xlinkHref={`#${STORY_ID}`} />;
|
||||
if (type === 'success') return <use xlinkHref={`#${SUCCESS_ID}`} />;
|
||||
if (type === 'error') return <use xlinkHref={`#${ERROR_ID}`} />;
|
||||
if (type === 'warning') return <use xlinkHref={`#${WARNING_ID}`} />;
|
||||
if (type === 'dot') return <use xlinkHref={`#${DOT_ID}`} />;
|
||||
return null;
|
||||
};
|
||||
|
@ -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 (
|
||||
<ResultRow {...props} onClick={click}>
|
||||
@ -218,7 +221,7 @@ const Result: FC<
|
||||
))}
|
||||
</Path>
|
||||
</ResultRowContent>
|
||||
{item.status ? i : null}
|
||||
{item.status ? <StatusLabel status={item.status}>{icon}</StatusLabel> : null}
|
||||
</ResultRow>
|
||||
);
|
||||
});
|
||||
|
@ -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) => (
|
||||
<ManagerContext.Provider
|
||||
value={
|
||||
{
|
||||
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'),
|
||||
},
|
||||
} as any
|
||||
}
|
||||
>
|
||||
<ManagerContext.Provider value={managerContext}>
|
||||
<LayoutProvider>
|
||||
<IconSymbols />
|
||||
{storyFn()}
|
||||
@ -229,41 +226,29 @@ export const Searching: Story = {
|
||||
};
|
||||
|
||||
export const Bottom: Story = {
|
||||
args: {
|
||||
bottom: [
|
||||
{
|
||||
id: '1',
|
||||
type: types.experimental_SIDEBAR_BOTTOM,
|
||||
render: () => (
|
||||
<Button>
|
||||
<FaceHappyIcon />
|
||||
Custom addon A
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: types.experimental_SIDEBAR_BOTTOM,
|
||||
render: () => (
|
||||
<Button>
|
||||
{' '}
|
||||
<FaceHappyIcon />
|
||||
Custom addon B
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: types.experimental_SIDEBAR_BOTTOM,
|
||||
render: () => (
|
||||
<IconButton>
|
||||
{' '}
|
||||
<FaceHappyIcon />
|
||||
</IconButton>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
decorators: [
|
||||
(storyFn) => (
|
||||
<ManagerContext.Provider
|
||||
value={{
|
||||
...managerContext,
|
||||
state: {
|
||||
...managerContext.state,
|
||||
status: {
|
||||
[storyId]: {
|
||||
vitest: { status: 'warn', title: '', description: '' },
|
||||
vta: { status: 'error', title: '', description: '' },
|
||||
},
|
||||
'root-1-child-a2--grandchild-a1-2': {
|
||||
vitest: { status: 'warn', title: '', description: '' },
|
||||
},
|
||||
} satisfies API_StatusState,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{storyFn()}
|
||||
</ManagerContext.Provider>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -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({
|
||||
</ScrollArea>
|
||||
{isLoading ? null : (
|
||||
<Bottom className="sb-bar">
|
||||
{bottom.map(({ id, render: Render }) => (
|
||||
<Render key={id} />
|
||||
))}
|
||||
<SidebarBottom />
|
||||
</Bottom>
|
||||
)}
|
||||
</Container>
|
||||
|
@ -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' } },
|
||||
},
|
||||
},
|
||||
};
|
93
code/core/src/manager/components/sidebar/SidebarBottom.tsx
Normal file
93
code/core/src/manager/components/sidebar/SidebarBottom.tsx
Normal file
@ -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 (
|
||||
<Wrapper id="sidebar-bottom-wrapper">
|
||||
{hasErrors && (
|
||||
<FilterToggle
|
||||
id="errors-found-filter"
|
||||
active={showErrors}
|
||||
count={errors.length}
|
||||
label="Error"
|
||||
status="critical"
|
||||
onClick={toggleErrors}
|
||||
/>
|
||||
)}
|
||||
{hasWarnings && (
|
||||
<FilterToggle
|
||||
id="warnings-found-filter"
|
||||
active={showWarnings}
|
||||
count={warnings.length}
|
||||
label="Warning"
|
||||
status="warning"
|
||||
onClick={toggleWarnings}
|
||||
/>
|
||||
)}
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export const SidebarBottom = () => {
|
||||
const api = useStorybookApi();
|
||||
const { status } = useStorybookState();
|
||||
return <SidebarBottomBase api={api} status={status} />;
|
||||
};
|
64
code/core/src/manager/components/sidebar/StatusButton.tsx
Normal file
64
code/core/src/manager/components/sidebar/StatusButton.tsx
Normal file
@ -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)',
|
||||
},
|
||||
}
|
||||
);
|
40
code/core/src/manager/components/sidebar/StatusContext.tsx
Normal file
40
code/core/src/manager/components/sidebar/StatusContext.tsx
Normal file
@ -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<StoryId, API_StatusValue>;
|
||||
}>({});
|
||||
|
||||
export const useStatusSummary = (item: GroupEntry | ComponentEntry) => {
|
||||
const { data, status, groupStatus } = useContext(StatusContext);
|
||||
const summary: {
|
||||
counts: Record<API_StatusValue, number>;
|
||||
statuses: Record<API_StatusValue, Record<StoryId, API_StatusObject[]>>;
|
||||
} = {
|
||||
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;
|
||||
};
|
@ -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<StoryId, API_StatusValue>;
|
||||
api: API;
|
||||
}
|
||||
|
||||
const Node = React.memo<NodeProps>(function Node({
|
||||
item,
|
||||
status,
|
||||
groupStatus,
|
||||
refId,
|
||||
docsMode,
|
||||
isOrphan,
|
||||
isDisplayed,
|
||||
isSelected,
|
||||
isFullyExpanded,
|
||||
color,
|
||||
setFullyExpanded,
|
||||
isExpanded,
|
||||
setExpanded,
|
||||
@ -185,6 +152,7 @@ const Node = React.memo<NodeProps>(function Node({
|
||||
api,
|
||||
}) {
|
||||
const { isDesktop, isMobile, setMobileMenuOpen } = useLayout();
|
||||
const theme = useTheme();
|
||||
|
||||
if (!isDisplayed) {
|
||||
return null;
|
||||
@ -197,20 +165,22 @@ const Node = React.memo<NodeProps>(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 (
|
||||
<LeafNodeStyleWrapper
|
||||
key={id}
|
||||
className="sidebar-item"
|
||||
data-selected={isSelected}
|
||||
data-ref-id={refId}
|
||||
data-item-id={item.id}
|
||||
data-parent-id={item.parent}
|
||||
data-nodetype={item.type === 'docs' ? 'document' : 'story'}
|
||||
data-highlightable={isDisplayed}
|
||||
className="sidebar-item"
|
||||
>
|
||||
<LeafNode
|
||||
// @ts-expect-error (non strict)
|
||||
style={isSelected ? {} : { color: textColor }}
|
||||
key={id}
|
||||
href={getLink(item, refId)}
|
||||
id={id}
|
||||
depth={isOrphan ? item.depth : item.depth - 1}
|
||||
@ -231,23 +201,37 @@ const Node = React.memo<NodeProps>(function Node({
|
||||
)}
|
||||
{icon ? (
|
||||
<WithTooltip
|
||||
placement="top"
|
||||
style={{ display: 'flex' }}
|
||||
closeOnOutsideClick
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
placement="bottom"
|
||||
tooltip={() => (
|
||||
<TooltipLinkList
|
||||
links={Object.entries(status || {}).map(([k, v]) => ({
|
||||
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: <StatusPassIcon color={theme.color.positive} />,
|
||||
error: <StatusFailIcon color={theme.color.negative} />,
|
||||
warn: <StatusWarnIcon color={theme.color.warning} />,
|
||||
pending: <SyncIcon size={12} color={theme.color.defaultText} />,
|
||||
unknown: null,
|
||||
}[value.status],
|
||||
onClick: () => {
|
||||
onSelectStoryId(item.id);
|
||||
value.onClick?.();
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
closeOnOutsideClick
|
||||
>
|
||||
<Action type="button" height={22}>
|
||||
<StatusButton type="button" status={statusValue} selectedItem={isSelected}>
|
||||
{icon}
|
||||
</Action>
|
||||
</StatusButton>
|
||||
</WithTooltip>
|
||||
) : null}
|
||||
</LeafNodeStyleWrapper>
|
||||
@ -296,41 +280,96 @@ const Node = React.memo<NodeProps>(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<typeof TooltipLinkList>['links'] = (
|
||||
onHide
|
||||
) => {
|
||||
const links = [];
|
||||
if (counts.error) {
|
||||
links.push({
|
||||
id: 'errors',
|
||||
icon: <StatusFailIcon color={theme.color.negative} />,
|
||||
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: <StatusWarnIcon color={theme.color.gold} />,
|
||||
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 (
|
||||
<BranchNode
|
||||
<LeafNodeStyleWrapper
|
||||
key={id}
|
||||
id={id}
|
||||
style={color ? { color } : {}}
|
||||
className="sidebar-item"
|
||||
data-ref-id={refId}
|
||||
data-item-id={item.id}
|
||||
data-parent-id={item.parent}
|
||||
data-nodetype={item.type === 'component' ? 'component' : 'group'}
|
||||
data-highlightable={isDisplayed}
|
||||
aria-controls={item.children && item.children[0]}
|
||||
aria-expanded={isExpanded}
|
||||
depth={isOrphan ? item.depth : item.depth - 1}
|
||||
isComponent={item.type === 'component'}
|
||||
isExpandable={item.children && item.children.length > 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}
|
||||
</BranchNode>
|
||||
<BranchNode
|
||||
id={id}
|
||||
style={color ? { color } : {}}
|
||||
aria-controls={item.children && item.children[0]}
|
||||
aria-expanded={isExpanded}
|
||||
depth={isOrphan ? item.depth : item.depth - 1}
|
||||
isComponent={item.type === 'component'}
|
||||
isExpandable={item.children && item.children.length > 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}
|
||||
</BranchNode>
|
||||
{['error', 'warn'].includes(itemStatus) && (
|
||||
<WithTooltip
|
||||
closeOnOutsideClick
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
placement="bottom"
|
||||
tooltip={({ onHide }) => <TooltipLinkList links={createLinks(onHide)} />}
|
||||
>
|
||||
<StatusButton type="button" status={itemStatus}>
|
||||
<svg key="icon" viewBox="0 0 6 6" width="6" height="6" type="dot">
|
||||
<UseSymbol type="dot" />
|
||||
</svg>
|
||||
</StatusButton>
|
||||
</WithTooltip>
|
||||
)}
|
||||
</LeafNodeStyleWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<Node
|
||||
@ -522,11 +560,9 @@ export const Tree = React.memo<{
|
||||
key={id}
|
||||
item={item}
|
||||
// @ts-expect-error (non strict)
|
||||
|
||||
status={status?.[itemId]}
|
||||
groupStatus={groupStatus}
|
||||
refId={refId}
|
||||
// @ts-expect-error (non strict)
|
||||
color={color}
|
||||
docsMode={docsMode}
|
||||
isOrphan={orphanIds.some((oid) => itemId === oid || itemId.startsWith(`${oid}-`))}
|
||||
isDisplayed={isDisplayed}
|
||||
@ -554,9 +590,11 @@ export const Tree = React.memo<{
|
||||
status,
|
||||
]);
|
||||
return (
|
||||
<Container ref={containerRef} hasOrphans={isMain && orphanIds.length > 0}>
|
||||
<IconSymbols />
|
||||
{treeItems}
|
||||
</Container>
|
||||
<StatusContext.Provider value={{ data, status, groupStatus }}>
|
||||
<Container ref={containerRef} hasOrphans={isMain && orphanIds.length > 0}>
|
||||
<IconSymbols />
|
||||
{treeItems}
|
||||
</Container>
|
||||
</StatusContext.Provider>
|
||||
);
|
||||
});
|
||||
|
@ -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',
|
||||
|
@ -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<PropsWithChildren<StoryLinkWrapperProps>> = ({
|
||||
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: <Shortcut keys={['⌘']} />,
|
||||
href: 'http://google.com',
|
||||
onClick: onLinkClick,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
@ -66,9 +46,9 @@ export const WithShortcuts = {
|
||||
center: 'This is an addition description',
|
||||
right: <Shortcut keys={['⌘', 'K']} />,
|
||||
href: 'http://google.com',
|
||||
onClick: onLinkClick,
|
||||
},
|
||||
],
|
||||
LinkWrapper: StoryLinkWrapper,
|
||||
},
|
||||
} satisfies Story;
|
||||
|
||||
@ -82,6 +62,7 @@ export const WithShortcutsActive = {
|
||||
active: true,
|
||||
right: <Shortcut keys={['⌘']} />,
|
||||
href: 'http://google.com',
|
||||
onClick: onLinkClick,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
@ -89,8 +70,8 @@ export const WithShortcutsActive = {
|
||||
center: 'This is an addition description',
|
||||
right: <Shortcut keys={['⌘', 'K']} />,
|
||||
href: 'http://google.com',
|
||||
onClick: onLinkClick,
|
||||
},
|
||||
],
|
||||
LinkWrapper: StoryLinkWrapper,
|
||||
},
|
||||
} satisfies Story;
|
||||
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
@ -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',
|
||||
|
@ -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<API_StatusValue, [ReactElement | null, string | null]> = {
|
||||
unknown: [null, null],
|
||||
pending: [<LoadingIcons key="icon" />, 'currentColor'],
|
||||
success: [<SmallIcons key="icon" style={{ color: 'green' }} />, 'currentColor'],
|
||||
warn: [<SmallIcons key="icon" style={{ color: 'orange' }} />, '#A15C20'],
|
||||
error: [<SmallIcons key="icon" style={{ color: 'red' }} />, 'brown'],
|
||||
success: [
|
||||
<svg key="icon" viewBox="0 0 14 14" width="14" height="14">
|
||||
<UseSymbol type="success" />
|
||||
</svg>,
|
||||
'currentColor',
|
||||
],
|
||||
warn: [
|
||||
<svg key="icon" viewBox="0 0 14 14" width="14" height="14">
|
||||
<UseSymbol type="warning" />
|
||||
</svg>,
|
||||
'#A15C20',
|
||||
],
|
||||
error: [
|
||||
<svg key="icon" viewBox="0 0 14 14" width="14" height="14">
|
||||
<UseSymbol type="error" />
|
||||
</svg>,
|
||||
'brown',
|
||||
],
|
||||
};
|
||||
|
||||
export const getHighestStatus = (statuses: API_StatusValue[]): API_StatusValue => {
|
||||
|
@ -54,6 +54,7 @@ export {
|
||||
normalizeStory,
|
||||
filterArgTypes,
|
||||
sanitizeStoryContextUpdate,
|
||||
setDefaultProjectAnnotations,
|
||||
setProjectAnnotations,
|
||||
inferControls,
|
||||
userOrAutoTitleFromSpecifier,
|
||||
|
@ -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<TFieldType = any>(
|
||||
|
||||
export function composeConfigs<TRenderer extends Renderer>(
|
||||
moduleExportList: ModuleExports[]
|
||||
): ProjectAnnotations<TRenderer> {
|
||||
): NormalizedProjectAnnotations<TRenderer> {
|
||||
const allArgTypeEnhancers = getArrayField(moduleExportList, 'argTypesEnhancers');
|
||||
const stepRunners = getField(moduleExportList, 'runStep');
|
||||
const beforeAllHooks = getArrayField(moduleExportList, 'beforeAll');
|
||||
|
@ -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<any> = {};
|
||||
// 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<any>;
|
||||
// eslint-disable-next-line no-var
|
||||
var defaultProjectAnnotations: ProjectAnnotations<any>;
|
||||
}
|
||||
|
||||
export function setDefaultProjectAnnotations<TRenderer extends Renderer = Renderer>(
|
||||
_defaultProjectAnnotations: ProjectAnnotations<TRenderer>
|
||||
) {
|
||||
// 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<TRenderer extends Renderer = Renderer>(
|
||||
projectAnnotations:
|
||||
| NamedOrDefaultProjectAnnotations<TRenderer>
|
||||
| NamedOrDefaultProjectAnnotations<TRenderer>[]
|
||||
): ProjectAnnotations<TRenderer> {
|
||||
): NormalizedProjectAnnotations<TRenderer> {
|
||||
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<TRenderer extends Renderer = Renderer, TArgs extend
|
||||
);
|
||||
|
||||
const normalizedProjectAnnotations = normalizeProjectAnnotations<TRenderer>(
|
||||
composeConfigs([defaultConfig ?? {}, globalProjectAnnotations, projectAnnotations ?? {}])
|
||||
composeConfigs([
|
||||
defaultConfig && Object.keys(defaultConfig).length > 0
|
||||
? defaultConfig
|
||||
: globalThis.defaultProjectAnnotations ?? {},
|
||||
globalThis.globalProjectAnnotations ?? {},
|
||||
projectAnnotations ?? {},
|
||||
])
|
||||
);
|
||||
|
||||
const story = prepareStory<TRenderer>(
|
||||
@ -219,10 +239,13 @@ export function composeStory<TRenderer extends Renderer = Renderer, TArgs extend
|
||||
return composedStory;
|
||||
}
|
||||
|
||||
const defaultComposeStory: ComposeStoryFn = (story, component, project, exportsName) =>
|
||||
composeStory(story, component, project, {}, exportsName);
|
||||
|
||||
export function composeStories<TModule extends Store_CSFExports>(
|
||||
storiesImport: TModule,
|
||||
globalConfig: ProjectAnnotations<Renderer>,
|
||||
composeStoryFn: ComposeStoryFn
|
||||
composeStoryFn: ComposeStoryFn = defaultComposeStory
|
||||
) {
|
||||
const { default: meta, __esModule, __namedExportsOrder, ...stories } = storiesImport;
|
||||
const composedStories = Object.entries(stories).reduce((storiesMap, [exportsName, story]) => {
|
||||
|
@ -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(<Story/>);
|
||||
},
|
||||
});`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class NoRenderFunctionError extends StorybookError {
|
||||
constructor(public data: { id: string }) {
|
||||
super({
|
||||
|
@ -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}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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`);
|
||||
});
|
||||
});
|
||||
|
@ -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;
|
||||
};
|
||||
|
||||
|
@ -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',
|
||||
}
|
||||
|
@ -125,6 +125,7 @@ export interface API_StatusObject {
|
||||
title: string;
|
||||
description: string;
|
||||
data?: any;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export type API_StatusState = Record<StoryId, Record<string, API_StatusObject>>;
|
||||
|
@ -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'],
|
||||
};
|
||||
|
@ -67,4 +67,5 @@ export const Hooks = {
|
||||
});
|
||||
await new Promise((resolve) => channel.once(STORY_ARGS_UPDATED, resolve));
|
||||
},
|
||||
tags: ['!vitest'],
|
||||
};
|
||||
|
@ -44,6 +44,7 @@ export const Events = {
|
||||
await channel.emit('updateGlobals', { globals: { foo: 'fooValue' } });
|
||||
await within(canvasElement).findByText('fooValue');
|
||||
},
|
||||
tags: ['!vitest'],
|
||||
};
|
||||
|
||||
export const Overrides1 = {
|
||||
|
@ -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 = {
|
||||
|
@ -16,7 +16,7 @@ export default {
|
||||
};
|
||||
|
||||
export const Inheritance = {
|
||||
tags: ['story-one'],
|
||||
tags: ['story-one', '!vitest'],
|
||||
play: async ({ canvasElement }: PlayFunctionContext<any>) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(JSON.parse(canvas.getByTestId('pre').innerText)).toEqual({
|
||||
|
@ -16,7 +16,7 @@ export default {
|
||||
};
|
||||
|
||||
export const Inheritance = {
|
||||
tags: ['story-one'],
|
||||
tags: ['story-one', '!vitest'],
|
||||
play: async ({ canvasElement }: PlayFunctionContext<any>) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(JSON.parse(canvas.getByTestId('pre').innerText)).toEqual({
|
||||
|
@ -16,7 +16,7 @@ export default {
|
||||
};
|
||||
|
||||
export const Inheritance = {
|
||||
tags: ['story-one'],
|
||||
tags: ['story-one', '!vitest'],
|
||||
play: async ({ canvasElement }: PlayFunctionContext<any>) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(JSON.parse(canvas.getByTestId('pre').innerText)).toEqual({
|
||||
|
@ -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<ReactRenderer>
|
||||
| NamedOrDefaultProjectAnnotations<ReactRenderer>[]
|
||||
): ProjectAnnotations<ReactRenderer> {
|
||||
return originalSetProjectAnnotations<ReactRenderer>(projectAnnotations);
|
||||
| NamedOrDefaultProjectAnnotations<any>
|
||||
| NamedOrDefaultProjectAnnotations<any>[]
|
||||
): NormalizedProjectAnnotations<ReactRenderer> {
|
||||
setDefaultProjectAnnotations(INTERNAL_DEFAULT_PROJECT_ANNOTATIONS);
|
||||
return originalSetProjectAnnotations(
|
||||
projectAnnotations
|
||||
) as NormalizedProjectAnnotations<ReactRenderer>;
|
||||
}
|
||||
|
||||
// This will not be necessary once we have auto preset loading
|
||||
const defaultProjectAnnotations: ProjectAnnotations<ReactRenderer> = composeConfigs([
|
||||
const INTERNAL_DEFAULT_PROJECT_ANNOTATIONS: ProjectAnnotations<ReactRenderer> = composeConfigs([
|
||||
reactAnnotations,
|
||||
rscAnnotations,
|
||||
nextJsAnnotations,
|
||||
]);
|
||||
|
||||
@ -62,7 +65,7 @@ const defaultProjectAnnotations: ProjectAnnotations<ReactRenderer> = 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<TArgs extends Args = Args>(
|
||||
story as StoryAnnotationsOrFn<ReactRenderer, Args>,
|
||||
componentAnnotations,
|
||||
projectAnnotations,
|
||||
defaultProjectAnnotations,
|
||||
INTERNAL_DEFAULT_PROJECT_ANNOTATIONS,
|
||||
exportsName
|
||||
);
|
||||
}
|
||||
@ -104,7 +107,7 @@ export function composeStory<TArgs extends Args = Args>(
|
||||
* 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);
|
||||
|
@ -8,6 +8,7 @@ import NextHeader from './NextHeader';
|
||||
|
||||
export default {
|
||||
component: NextHeader,
|
||||
parameters: { react: { rsc: true } },
|
||||
} as Meta<typeof NextHeader>;
|
||||
|
||||
type Story = StoryObj<typeof NextHeader>;
|
||||
|
@ -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 },
|
||||
},
|
||||
|
@ -46,7 +46,7 @@ export default {
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['!test'],
|
||||
tags: ['!test', '!vitest'],
|
||||
} as Meta;
|
||||
|
||||
export const SingletonStateGetsInvalidatedAfterRedirecting: StoryObj = {
|
||||
|
@ -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<ReactRenderer>
|
||||
| NamedOrDefaultProjectAnnotations<ReactRenderer>[]
|
||||
): ProjectAnnotations<ReactRenderer> {
|
||||
return originalSetProjectAnnotations<ReactRenderer>(projectAnnotations);
|
||||
| NamedOrDefaultProjectAnnotations<any>
|
||||
| NamedOrDefaultProjectAnnotations<any>[]
|
||||
): NormalizedProjectAnnotations<ReactRenderer> {
|
||||
setDefaultProjectAnnotations(INTERNAL_DEFAULT_PROJECT_ANNOTATIONS);
|
||||
return originalSetProjectAnnotations(
|
||||
projectAnnotations
|
||||
) as NormalizedProjectAnnotations<ReactRenderer>;
|
||||
}
|
||||
|
||||
// This will not be necessary once we have auto preset loading
|
||||
const defaultProjectAnnotations: ProjectAnnotations<ReactRenderer> = composeConfigs([
|
||||
const INTERNAL_DEFAULT_PROJECT_ANNOTATIONS: ProjectAnnotations<ReactRenderer> = composeConfigs([
|
||||
reactAnnotations,
|
||||
rscAnnotations,
|
||||
nextJsAnnotations,
|
||||
]);
|
||||
|
||||
@ -88,7 +91,7 @@ export function composeStory<TArgs extends Args = Args>(
|
||||
story as StoryAnnotationsOrFn<ReactRenderer, Args>,
|
||||
componentAnnotations,
|
||||
projectAnnotations,
|
||||
defaultProjectAnnotations,
|
||||
INTERNAL_DEFAULT_PROJECT_ANNOTATIONS,
|
||||
exportsName
|
||||
);
|
||||
}
|
||||
|
@ -5,6 +5,11 @@ import { Nested, RSC } from './RSC';
|
||||
export default {
|
||||
component: RSC,
|
||||
args: { label: 'label' },
|
||||
parameters: {
|
||||
react: {
|
||||
rsc: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Default = {};
|
||||
|
@ -7,6 +7,11 @@ import NextHeader from './NextHeader';
|
||||
|
||||
export default {
|
||||
component: NextHeader,
|
||||
parameters: {
|
||||
react: {
|
||||
rsc: true,
|
||||
},
|
||||
},
|
||||
} as Meta<typeof NextHeader>;
|
||||
|
||||
type Story = StoryObj<typeof NextHeader>;
|
||||
|
@ -2,6 +2,6 @@
|
||||
import { enhance } from '$app/forms';
|
||||
</script>
|
||||
|
||||
<form use:enhance>
|
||||
<form use:enhance method="post">
|
||||
<button>enhance</button>
|
||||
</form>
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user