diff --git a/code/.storybook/preview.tsx b/code/.storybook/preview.tsx index 60135ac5a8c5..fac0c9bdb2b1 100644 --- a/code/.storybook/preview.tsx +++ b/code/.storybook/preview.tsx @@ -117,8 +117,8 @@ const ThemedSetRoot = () => { }; // eslint-disable-next-line no-underscore-dangle -const preview = (window as any).__STORYBOOK_PREVIEW__ as PreviewWeb; -const channel = (window as any).__STORYBOOK_ADDONS_CHANNEL__ as Channel; +const preview = (window as any).__STORYBOOK_PREVIEW__ as PreviewWeb | undefined; +const channel = (window as any).__STORYBOOK_ADDONS_CHANNEL__ as Channel | undefined; export const loaders = [ /** * This loader adds a DocsContext to the story, which is required for the most Blocks to work. A @@ -133,9 +133,9 @@ export const loaders = [ * The DocsContext will then be added via the decorator below. */ async ({ parameters: { relativeCsfPaths, attached = true } }) => { - // TODO bring a better way to skip tests when running as part of the vitest plugin instead of __STORYBOOK_URL__ - // eslint-disable-next-line no-underscore-dangle - if (!relativeCsfPaths || (import.meta as any).env?.__STORYBOOK_URL__) { + // __STORYBOOK_PREVIEW__ and __STORYBOOK_ADDONS_CHANNEL__ is set in the PreviewWeb constructor + // which isn't loaded in portable stories/vitest + if (!relativeCsfPaths || !preview || !channel) { return {}; } const csfFiles = await Promise.all( diff --git a/code/addons/test/src/components/ContextMenuItem.tsx b/code/addons/test/src/components/ContextMenuItem.tsx new file mode 100644 index 000000000000..5cc6b58297a1 --- /dev/null +++ b/code/addons/test/src/components/ContextMenuItem.tsx @@ -0,0 +1,101 @@ +import React, { + type FC, + type SyntheticEvent, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; + +import { Button, type ListItem } from 'storybook/internal/components'; +import { useStorybookApi } from 'storybook/internal/manager-api'; +import { useTheme } from 'storybook/internal/theming'; +import { type API_HashEntry, type Addon_TestProviderState } from 'storybook/internal/types'; + +import { PlayHollowIcon, StopAltHollowIcon } from '@storybook/icons'; + +import { TEST_PROVIDER_ID } from '../constants'; +import type { TestResult } from '../node/reporter'; +import { RelativeTime } from './RelativeTime'; + +export const ContextMenuItem: FC<{ + context: API_HashEntry; + state: Addon_TestProviderState<{ + testResults: TestResult[]; + }>; + ListItem: typeof ListItem; +}> = ({ context, state, ListItem }) => { + const api = useStorybookApi(); + const [isDisabled, setDisabled] = useState(false); + + const id = useRef(context.id); + id.current = context.id; + + const Icon = state.running ? StopAltHollowIcon : PlayHollowIcon; + + useEffect(() => { + setDisabled(false); + }, [state.running]); + + const onClick = useCallback( + (event: SyntheticEvent) => { + setDisabled(true); + event.stopPropagation(); + if (state.running) { + api.cancelTestProvider(TEST_PROVIDER_ID); + } else { + api.runTestProvider(TEST_PROVIDER_ID, { entryId: id.current }); + } + }, + [api, state.running] + ); + + const theme = useTheme(); + + const title = state.crashed || state.failed ? 'Component tests failed' : 'Component tests'; + const errorMessage = state.error?.message; + let description: string | React.ReactNode = 'Not run'; + + if (state.running) { + description = state.progress + ? `Testing... ${state.progress.numPassedTests}/${state.progress.numTotalTests}` + : 'Starting...'; + } else if (state.failed && !errorMessage) { + description = ''; + } else if (state.crashed || (state.failed && errorMessage)) { + description = 'An error occured'; + } else if (state.progress?.finishedAt) { + description = ( + + ); + } else if (state.watching) { + description = 'Watching for file changes'; + } + + return ( +
{ + // stopPropagation to prevent the parent from closing the context menu, which is the default behavior onClick + event.stopPropagation(); + }} + > + + + + } + /> +
+ ); +}; diff --git a/code/addons/test/src/Panel.test.ts b/code/addons/test/src/components/Panel.test.ts similarity index 100% rename from code/addons/test/src/Panel.test.ts rename to code/addons/test/src/components/Panel.test.ts diff --git a/code/addons/test/src/Panel.tsx b/code/addons/test/src/components/Panel.tsx similarity index 98% rename from code/addons/test/src/Panel.tsx rename to code/addons/test/src/components/Panel.tsx index 0cc93575b35a..584872bad8c3 100644 --- a/code/addons/test/src/Panel.tsx +++ b/code/addons/test/src/components/Panel.tsx @@ -19,8 +19,8 @@ import { global } from '@storybook/global'; import { type Call, CallStates, EVENTS, type LogItem } from '@storybook/instrumenter'; import type { API_StatusValue } from '@storybook/types'; -import { InteractionsPanel } from './components/InteractionsPanel'; -import { ADDON_ID, TEST_PROVIDER_ID } from './constants'; +import { ADDON_ID, TEST_PROVIDER_ID } from '../constants'; +import { InteractionsPanel } from './InteractionsPanel'; interface Interaction extends Call { status: Call['status']; diff --git a/code/addons/test/src/components/PanelTitle.tsx b/code/addons/test/src/components/PanelTitle.tsx new file mode 100644 index 000000000000..4ef1ddedd302 --- /dev/null +++ b/code/addons/test/src/components/PanelTitle.tsx @@ -0,0 +1,23 @@ +import React from 'react'; + +import { Badge, Spaced } from 'storybook/internal/components'; +import { useAddonState } from 'storybook/internal/manager-api'; + +import { ADDON_ID } from '../constants'; + +export function PanelTitle() { + const [addonState = {}] = useAddonState(ADDON_ID); + const { hasException, interactionsCount } = addonState as any; + + return ( +
+ + Component tests + {interactionsCount && !hasException ? ( + {interactionsCount} + ) : null} + {hasException ? {interactionsCount} : null} + +
+ ); +} diff --git a/code/addons/test/src/components/RelativeTime.tsx b/code/addons/test/src/components/RelativeTime.tsx new file mode 100644 index 000000000000..d643960b06ed --- /dev/null +++ b/code/addons/test/src/components/RelativeTime.tsx @@ -0,0 +1,24 @@ +import { useEffect, useState } from 'react'; + +import { getRelativeTimeString } from '../manager'; + +export const RelativeTime = ({ timestamp, testCount }: { timestamp: Date; testCount: number }) => { + const [relativeTimeString, setRelativeTimeString] = useState(null); + + useEffect(() => { + if (timestamp) { + setRelativeTimeString(getRelativeTimeString(timestamp).replace(/^now$/, 'just now')); + + const interval = setInterval(() => { + setRelativeTimeString(getRelativeTimeString(timestamp).replace(/^now$/, 'just now')); + }, 10000); + + return () => clearInterval(interval); + } + }, [timestamp]); + + return ( + relativeTimeString && + `Ran ${testCount} ${testCount === 1 ? 'test' : 'tests'} ${relativeTimeString}` + ); +}; diff --git a/code/addons/test/src/manager.tsx b/code/addons/test/src/manager.tsx index 0b558a3d4a20..9b560cdd8cee 100644 --- a/code/addons/test/src/manager.tsx +++ b/code/addons/test/src/manager.tsx @@ -1,9 +1,10 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useState } from 'react'; -import { AddonPanel, Badge, Link as LinkComponent, Spaced } from 'storybook/internal/components'; +import { AddonPanel, Button, Link as LinkComponent } from 'storybook/internal/components'; import { TESTING_MODULE_RUN_ALL_REQUEST } from 'storybook/internal/core-events'; import type { Combo } from 'storybook/internal/manager-api'; -import { Consumer, addons, types, useAddonState } from 'storybook/internal/manager-api'; +import { Consumer, addons, types } from 'storybook/internal/manager-api'; +import { styled } from 'storybook/internal/theming'; import { type API_StatusObject, type API_StatusValue, @@ -11,28 +12,16 @@ import { Addon_TypesEnum, } from 'storybook/internal/types'; -import { Panel } from './Panel'; +import { EyeIcon, PlayHollowIcon, StopAltHollowIcon } from '@storybook/icons'; + +import { ContextMenuItem } from './components/ContextMenuItem'; import { GlobalErrorModal } from './components/GlobalErrorModal'; +import { Panel } from './components/Panel'; +import { PanelTitle } from './components/PanelTitle'; +import { RelativeTime } from './components/RelativeTime'; import { ADDON_ID, PANEL_ID, TEST_PROVIDER_ID } from './constants'; import type { TestResult } from './node/reporter'; -function Title() { - const [addonState = {}] = useAddonState(ADDON_ID); - const { hasException, interactionsCount } = addonState as any; - - return ( -
- - Component tests - {interactionsCount && !hasException ? ( - {interactionsCount} - ) : null} - {hasException ? {interactionsCount} : null} - -
- ); -} - const statusMap: Record = { failed: 'error', passed: 'success', @@ -58,26 +47,27 @@ export function getRelativeTimeString(date: Date): string { return rtf.format(Math.floor(delta / divisor), units[unitIndex]); } -const RelativeTime = ({ timestamp, testCount }: { timestamp: Date; testCount: number }) => { - const [relativeTimeString, setRelativeTimeString] = useState(null); - - useEffect(() => { - if (timestamp) { - setRelativeTimeString(getRelativeTimeString(timestamp).replace(/^now$/, 'just now')); +const Info = styled.div({ + display: 'flex', + flexDirection: 'column', + marginLeft: 6, +}); - const interval = setInterval(() => { - setRelativeTimeString(getRelativeTimeString(timestamp).replace(/^now$/, 'just now')); - }, 10000); +const SidebarContextMenuTitle = styled.div<{ crashed?: boolean }>(({ crashed, theme }) => ({ + fontSize: theme.typography.size.s1, + fontWeight: crashed ? 'bold' : 'normal', + color: crashed ? theme.color.negativeText : theme.color.defaultText, +})); - return () => clearInterval(interval); - } - }, [timestamp]); +const Description = styled.div(({ theme }) => ({ + fontSize: theme.typography.size.s1, + color: theme.barTextColor, +})); - return ( - relativeTimeString && - `Ran ${testCount} ${testCount === 1 ? 'test' : 'tests'} ${relativeTimeString}` - ); -}; +const Actions = styled.div({ + display: 'flex', + gap: 6, +}); addons.register(ADDON_ID, (api) => { const storybookBuilder = (globalThis as any).STORYBOOK_BUILDER || ''; @@ -91,25 +81,34 @@ addons.register(ADDON_ID, (api) => { type: Addon_TypesEnum.experimental_TEST_PROVIDER, runnable: true, watchable: true, - name: 'Component tests', - title: ({ crashed, failed }) => - crashed || failed ? 'Component tests failed' : 'Component tests', - description: ({ failed, running, watching, progress, crashed, error }) => { - const [isModalOpen, setIsModalOpen] = useState(false); - const errorMessage = error?.message; + sidebarContextMenu: ({ context, state }, { ListItem }) => { + if (context.type === 'docs') { + return null; + } + if (context.type === 'story' && !context.tags.includes('test')) { + return null; + } + + return ; + }, + + render: (state) => { + const [isModalOpen, setIsModalOpen] = useState(false); - let message: string | React.ReactNode = 'Not run'; + const title = state.crashed || state.failed ? 'Component tests failed' : 'Component tests'; + const errorMessage = state.error?.message; + let description: string | React.ReactNode = 'Not run'; - if (running) { - message = progress - ? `Testing... ${progress.numPassedTests}/${progress.numTotalTests}` + if (state.running) { + description = state.progress + ? `Testing... ${state.progress.numPassedTests}/${state.progress.numTotalTests}` : 'Starting...'; - } else if (failed && !errorMessage) { - message = ''; - } else if (crashed || (failed && errorMessage)) { - message = ( + } else if (state.failed && !errorMessage) { + description = ''; + } else if (state.crashed || (state.failed && errorMessage)) { + description = ( <> { setIsModalOpen(true); }} > - {error?.name || 'View full error'} + {state.error?.name || 'View full error'} ); - } else if (progress?.finishedAt) { - message = ( + } else if (state.progress?.finishedAt) { + description = ( ); - } else if (watching) { - message = 'Watching for file changes'; + } else if (state.watching) { + description = 'Watching for file changes'; } return ( <> - {message} + + + {title} + + {description} + + + + {state.watchable && ( + + )} + {state.runnable && ( + <> + {state.running && state.cancellable ? ( + + ) : ( + + )} + + )} + + { }>); } + const filter = ({ state }: Combo) => { + return { + storyId: state.storyId, + }; + }; + addons.add(PANEL_ID, { type: types.PANEL, - title: Title, + title: () => , match: ({ viewMode }) => viewMode === 'story', render: ({ active }) => { - const newLocal = useCallback(({ state }: Combo) => { - return { - storyId: state.storyId, - }; - }, []); - return ( - {({ storyId }) => } + {({ storyId }) => } ); }, diff --git a/code/addons/test/src/node/test-manager.test.ts b/code/addons/test/src/node/test-manager.test.ts index 415c9bbc7bb6..20763641d217 100644 --- a/code/addons/test/src/node/test-manager.test.ts +++ b/code/addons/test/src/node/test-manager.test.ts @@ -17,6 +17,9 @@ const vitest = vi.hoisted(() => ({ cancelCurrentRun: vi.fn(), globTestSpecs: vi.fn(), getModuleProjects: vi.fn(() => []), + configOverride: { + testNamePattern: undefined, + }, })); vi.mock('vitest/node', () => ({ @@ -84,12 +87,10 @@ describe('TestManager', () => { { stories: [], importPath: 'path/to/file', - componentPath: 'path/to/component', }, { stories: [], importPath: 'path/to/another/file', - componentPath: 'path/to/another/component', }, ], }); @@ -107,7 +108,6 @@ describe('TestManager', () => { { stories: [], importPath: 'path/to/unknown/file', - componentPath: 'path/to/unknown/component', }, ], }); @@ -119,7 +119,6 @@ describe('TestManager', () => { { stories: [], importPath: 'path/to/file', - componentPath: 'path/to/component', }, ], }); diff --git a/code/addons/test/src/node/vitest-manager.ts b/code/addons/test/src/node/vitest-manager.ts index ab31e1fc7694..f69f065aa42a 100644 --- a/code/addons/test/src/node/vitest-manager.ts +++ b/code/addons/test/src/node/vitest-manager.ts @@ -58,6 +58,7 @@ export class VitestManager { if (!this.vitest) { await this.startVitest(); } + this.resetTestNamePattern(); const storybookTests = await this.getStorybookTestSpecs(); for (const storybookTest of storybookTests) { @@ -87,6 +88,7 @@ export class VitestManager { if (!this.vitest) { await this.startVitest(); } + this.resetTestNamePattern(); // This list contains all the test files (story files) that need to be run // based on the test files that are passed in the tests array @@ -96,6 +98,8 @@ export class VitestManager { const storybookTests = await this.getStorybookTestSpecs(); + const filteredStoryNames: string[] = []; + for (const storybookTest of storybookTests) { const match = testPayload.find((test) => { const absoluteImportPath = path.join(process.cwd(), test.importPath); @@ -107,12 +111,29 @@ export class VitestManager { this.updateLastChanged(storybookTest.moduleId); } + if (match.stories?.length) { + filteredStoryNames.push(...match.stories.map((story) => story.name)); + } testList.push(storybookTest); } } await this.cancelCurrentRun(); + + if (filteredStoryNames.length > 0) { + // temporarily set the test name pattern to only run the selected stories + // converting a list of story names to a single regex pattern + // ie. ['My Story', 'Other Story'] => /^(My Story|Other Story)$/ + const testNamePattern = new RegExp( + `^(${filteredStoryNames + .map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('|')})$` + ); + this.vitest!.configOverride.testNamePattern = testNamePattern; + } + await this.vitest!.runFiles(testList, true); + this.resetTestNamePattern(); } async cancelCurrentRun() { @@ -173,6 +194,7 @@ export class VitestManager { if (!this.vitest) { return; } + this.resetTestNamePattern(); const globTestFiles = await this.vitest.globTestSpecs(); const testGraphs = await Promise.all( @@ -219,6 +241,7 @@ export class VitestManager { } async setupWatchers() { + this.resetTestNamePattern(); this.vitest?.server?.watcher.removeAllListeners('change'); this.vitest?.server?.watcher.removeAllListeners('add'); this.vitest?.server?.watcher.on('change', this.runAffectedTestsAfterChange.bind(this)); @@ -226,6 +249,12 @@ export class VitestManager { this.registerVitestConfigListener(); } + resetTestNamePattern() { + if (this.vitest) { + this.vitest.configOverride.testNamePattern = undefined; + } + } + isStorybookProject(project: TestProject | WorkspaceProject) { // eslint-disable-next-line no-underscore-dangle return !!project.config.env?.__STORYBOOK_URL__; diff --git a/code/core/package.json b/code/core/package.json index 87b3adc18de6..24795c3204d2 100644 --- a/code/core/package.json +++ b/code/core/package.json @@ -37,6 +37,16 @@ "import": "./dist/client-logger/index.js", "require": "./dist/client-logger/index.cjs" }, + "./theming": { + "types": "./dist/theming/index.d.ts", + "import": "./dist/theming/index.js", + "require": "./dist/theming/index.cjs" + }, + "./theming/create": { + "types": "./dist/theming/create.d.ts", + "import": "./dist/theming/create.js", + "require": "./dist/theming/create.cjs" + }, "./core-server": { "types": "./dist/core-server/index.d.ts", "import": "./dist/core-server/index.js", @@ -122,16 +132,6 @@ "import": "./dist/components/index.js", "require": "./dist/components/index.cjs" }, - "./theming": { - "types": "./dist/theming/index.d.ts", - "import": "./dist/theming/index.js", - "require": "./dist/theming/index.cjs" - }, - "./theming/create": { - "types": "./dist/theming/create.d.ts", - "import": "./dist/theming/create.js", - "require": "./dist/theming/create.cjs" - }, "./docs-tools": { "types": "./dist/docs-tools/index.d.ts", "import": "./dist/docs-tools/index.js", @@ -183,6 +183,12 @@ "client-logger": [ "./dist/client-logger/index.d.ts" ], + "theming": [ + "./dist/theming/index.d.ts" + ], + "theming/create": [ + "./dist/theming/create.d.ts" + ], "core-server": [ "./dist/core-server/index.d.ts" ], @@ -237,12 +243,6 @@ "components": [ "./dist/components/index.d.ts" ], - "theming": [ - "./dist/theming/index.d.ts" - ], - "theming/create": [ - "./dist/theming/create.d.ts" - ], "docs-tools": [ "./dist/docs-tools/index.d.ts" ], diff --git a/code/core/scripts/entries.ts b/code/core/scripts/entries.ts index e3b78e483990..bcd1d1b94572 100644 --- a/code/core/scripts/entries.ts +++ b/code/core/scripts/entries.ts @@ -9,6 +9,9 @@ export const getEntries = (cwd: string) => { define('src/node-logger/index.ts', ['node'], true), define('src/client-logger/index.ts', ['browser', 'node'], true), + define('src/theming/index.ts', ['browser', 'node'], true, ['react']), + define('src/theming/create.ts', ['browser', 'node'], true, ['react']), + define('src/core-server/index.ts', ['node'], true), define('src/core-server/presets/common-preset.ts', ['node'], false), define('src/core-server/presets/common-manager.ts', ['browser'], false), @@ -35,8 +38,6 @@ export const getEntries = (cwd: string) => { ['react', 'react-dom'], ['prettier'] // the syntax highlighter uses prettier/standalone to format the code ), - define('src/theming/index.ts', ['browser', 'node'], true, ['react']), - define('src/theming/create.ts', ['browser', 'node'], true, ['react']), define('src/docs-tools/index.ts', ['browser', 'node'], true), define('src/manager/globals-module-info.ts', ['node'], true), diff --git a/code/core/scripts/helpers/generateTypesFiles.ts b/code/core/scripts/helpers/generateTypesFiles.ts index fd7a7eff789d..4c04a20d41c3 100644 --- a/code/core/scripts/helpers/generateTypesFiles.ts +++ b/code/core/scripts/helpers/generateTypesFiles.ts @@ -3,6 +3,7 @@ import { join, relative } from 'node:path'; import { spawn } from '../../../../scripts/prepare/tools'; import { limit, picocolors, process } from '../../../../scripts/prepare/tools'; import type { getEntries } from '../entries'; +import { modifyThemeTypes } from './modifyThemeTypes'; export async function generateTypesFiles( entries: ReturnType, @@ -70,6 +71,11 @@ export async function generateTypesFiles( process.exit(dtsProcess.exitCode || 1); } else { console.log('Generated types for', picocolors.cyan(relative(cwd, dtsEntries[index]))); + + if (dtsEntries[index].includes('src/theming/index')) { + console.log('Modifying theme types'); + await modifyThemeTypes(); + } } }); }) diff --git a/code/core/scripts/helpers/modifyThemeTypes.ts b/code/core/scripts/helpers/modifyThemeTypes.ts index 5b35ca6be329..85d78a2cb07b 100644 --- a/code/core/scripts/helpers/modifyThemeTypes.ts +++ b/code/core/scripts/helpers/modifyThemeTypes.ts @@ -14,7 +14,7 @@ export async function modifyThemeTypes() { const contents = await readFile(target, 'utf-8'); const footer = contents.includes('// auto generated file') - ? `export { StorybookTheme as Theme } from '../src/index';` + ? `export { StorybookTheme as Theme } from '../../src/theming/index';` : dedent` interface Theme extends StorybookTheme {} export type { Theme }; diff --git a/code/core/scripts/prep.ts b/code/core/scripts/prep.ts index f8726e79df48..4d81c65b1d7e 100644 --- a/code/core/scripts/prep.ts +++ b/code/core/scripts/prep.ts @@ -66,7 +66,6 @@ async function run() { await generateTypesMapperFiles(entries); await modifyThemeTypes(); await generateTypesFiles(entries, isOptimized, cwd); - await modifyThemeTypes(); }) ); diff --git a/code/core/src/components/components/tooltip/TooltipLinkList.tsx b/code/core/src/components/components/tooltip/TooltipLinkList.tsx index 263c91d5bfa6..91e86eae71af 100644 --- a/code/core/src/components/components/tooltip/TooltipLinkList.tsx +++ b/code/core/src/components/components/tooltip/TooltipLinkList.tsx @@ -1,5 +1,5 @@ -import type { ComponentProps, SyntheticEvent } from 'react'; -import React, { useCallback } from 'react'; +import type { ComponentProps, ReactNode, SyntheticEvent } from 'react'; +import React, { Fragment, useCallback } from 'react'; import { styled } from '@storybook/core/theming'; @@ -15,7 +15,8 @@ const List = styled.div( }, ({ theme }) => ({ borderRadius: theme.appBorderRadius + 2, - }) + }), + ({ theme }) => (theme.base === 'dark' ? { background: theme.background.content } : {}) ); const Group = styled.div(({ theme }) => ({ @@ -25,7 +26,7 @@ const Group = styled.div(({ theme }) => ({ }, })); -export interface Link extends Omit { +export interface NormalLink extends Omit { id: string; onClick?: ( event: SyntheticEvent, @@ -33,7 +34,18 @@ export interface Link extends Omit { ) => void; } -interface ItemProps extends Link { +export type Link = CustomLink | NormalLink; + +/** + * This is a custom link that can be used in the `TooltipLinkList` component. It allows for custom + * content to be rendered in the list; it does not have to be a link. + */ +interface CustomLink { + id: string; + content: ReactNode; +} + +interface ItemProps extends NormalLink { isIndented?: boolean; } @@ -55,7 +67,7 @@ export interface TooltipLinkListProps extends ComponentProps { export const TooltipLinkList = ({ links, LinkWrapper, ...props }: TooltipLinkListProps) => { const groups = Array.isArray(links[0]) ? (links as Link[][]) : [links as Link[]]; - const isIndented = groups.some((group) => group.some((link) => link.icon)); + const isIndented = groups.some((group) => group.some((link) => 'icon' in link && link.icon)); return ( {groups @@ -63,9 +75,14 @@ export const TooltipLinkList = ({ links, LinkWrapper, ...props }: TooltipLinkLis .map((group, index) => { return ( link.id).join(`~${index}~`)}> - {group.map((link) => ( - - ))} + {group.map((link) => { + if ('content' in link) { + return {link.content}; + } + return ( + + ); + })} ); })} diff --git a/code/core/src/components/components/tooltip/WithTooltip.tsx b/code/core/src/components/components/tooltip/WithTooltip.tsx index ace6ac6ea275..c6bddae93795 100644 --- a/code/core/src/components/components/tooltip/WithTooltip.tsx +++ b/code/core/src/components/components/tooltip/WithTooltip.tsx @@ -121,7 +121,7 @@ const WithTooltipPure = ({ } ); - const tooltipComponent = ( + const tooltipComponent = isVisible ? ( onVisibleChange(false) }) : tooltip} - ); + ) : null; return ( <> diff --git a/code/core/src/core-events/data/testing-module.ts b/code/core/src/core-events/data/testing-module.ts index d9e9640935a1..ee483364d93e 100644 --- a/code/core/src/core-events/data/testing-module.ts +++ b/code/core/src/core-events/data/testing-module.ts @@ -8,17 +8,17 @@ export type TestProviderState = Addon_TestProviderState; export type TestProviders = Record; -export type TestingModuleRunRequestStories = { - id: string; - name: string; +export type TestingModuleRunRequestStory = { + id: string; // button--primary + name: string; // Primary }; export type TestingModuleRunRequestPayload = { providerId: TestProviderId; payload: { - stories: TestingModuleRunRequestStories[]; - importPath: string; - componentPath: string; + importPath: string; // ./.../button.stories.tsx + stories?: TestingModuleRunRequestStory[]; + componentPath?: string; // ./.../button.tsx }[]; }; diff --git a/code/core/src/manager-api/lib/store-setup.ts b/code/core/src/manager-api/lib/store-setup.ts index 291898bede0a..9928ae09c8fc 100644 --- a/code/core/src/manager-api/lib/store-setup.ts +++ b/code/core/src/manager-api/lib/store-setup.ts @@ -4,8 +4,13 @@ import { parse, stringify } from 'telejson'; // setting up the store, overriding set and get to use telejson export default (_: any) => { _.fn('set', function (key: string, data: object) { - // @ts-expect-error('this' implicitly has type 'any') - return _.set(this._area, this._in(key), stringify(data, { maxDepth: 50 })); + return _.set( + // @ts-expect-error('this' implicitly has type 'any') + this._area, + // @ts-expect-error('this' implicitly has type 'any') + this._in(key), + stringify(data, { maxDepth: 50, allowFunction: false }) + ); }); _.fn('get', function (key: string, alt: string) { // @ts-expect-error('this' implicitly has type 'any') diff --git a/code/core/src/manager-api/modules/addons.ts b/code/core/src/manager-api/modules/addons.ts index c620792e4fee..16a35fcfc9ad 100644 --- a/code/core/src/manager-api/modules/addons.ts +++ b/code/core/src/manager-api/modules/addons.ts @@ -30,6 +30,7 @@ export interface SubAPI { | Addon_Types | Addon_TypesEnum.experimental_PAGE | Addon_TypesEnum.experimental_SIDEBAR_BOTTOM + | Addon_TypesEnum.experimental_TEST_PROVIDER | Addon_TypesEnum.experimental_SIDEBAR_TOP = Addon_Types, >( type: T diff --git a/code/core/src/manager-api/modules/experimental_testmodule.ts b/code/core/src/manager-api/modules/experimental_testmodule.ts new file mode 100644 index 000000000000..54bfdc437da2 --- /dev/null +++ b/code/core/src/manager-api/modules/experimental_testmodule.ts @@ -0,0 +1,186 @@ +import { type API_StoryEntry, Addon_TypesEnum, type StoryId } from '@storybook/core/types'; + +import { + TESTING_MODULE_CANCEL_TEST_RUN_REQUEST, + TESTING_MODULE_RUN_ALL_REQUEST, + TESTING_MODULE_RUN_REQUEST, + TESTING_MODULE_WATCH_MODE_REQUEST, + type TestProviderId, + type TestProviderState, + type TestProviders, + type TestingModuleRunAllRequestPayload, + type TestingModuleRunRequestPayload, +} from '@storybook/core/core-events'; + +import invariant from 'tiny-invariant'; + +import type { ModuleFn } from '../lib/types'; + +export type SubState = { + testProviders: TestProviders; +}; + +const initialTestProviderState: TestProviderState = { + details: {} as { [key: string]: any }, + cancellable: false, + cancelling: false, + running: false, + watching: false, + failed: false, + crashed: false, +}; + +interface RunOptions { + entryId?: StoryId; +} + +export type SubAPI = { + getTestProviderState(id: string): TestProviderState | undefined; + updateTestProviderState(id: TestProviderId, update: Partial): void; + clearTestProviderState(id: TestProviderId): void; + runTestProvider(id: TestProviderId, options?: RunOptions): () => void; + setTestProviderWatchMode(id: TestProviderId, watchMode: boolean): void; + cancelTestProvider(id: TestProviderId): void; +}; + +export const init: ModuleFn = ({ store, fullAPI }) => { + const state: SubState = { + testProviders: store.getState().testProviders || {}, + }; + + const api: SubAPI = { + getTestProviderState(id) { + const { testProviders } = store.getState(); + + return testProviders?.[id]; + }, + updateTestProviderState(id, update) { + return store.setState( + ({ testProviders }) => { + return { testProviders: { ...testProviders, [id]: { ...testProviders[id], ...update } } }; + }, + { persistence: 'session' } + ); + }, + clearTestProviderState(id) { + const update = { + cancelling: false, + running: true, + failed: false, + crashed: false, + progress: undefined, + }; + return store.setState( + ({ testProviders }) => { + return { testProviders: { ...testProviders, [id]: { ...testProviders[id], ...update } } }; + }, + { persistence: 'session' } + ); + }, + runTestProvider(id, options) { + if (!options?.entryId) { + const payload: TestingModuleRunAllRequestPayload = { providerId: id }; + fullAPI.emit(TESTING_MODULE_RUN_ALL_REQUEST, payload); + return () => api.cancelTestProvider(id); + } + + const index = store.getState().index; + invariant(index, 'The index is currently unavailable'); + + const entry = index[options.entryId]; + + invariant(entry, `No entry found in the index for id '${options.entryId}'`); + + if (entry.type === 'story') { + const payload: TestingModuleRunRequestPayload = { + providerId: id, + payload: [ + { + importPath: entry.importPath, + stories: [ + { + id: entry.id, + name: entry.name, + }, + ], + }, + ], + }; + fullAPI.emit(TESTING_MODULE_RUN_REQUEST, payload); + return () => api.cancelTestProvider(id); + } + + const payloads = new Set(); + + const findComponents = (entryId: StoryId) => { + const foundEntry = index[entryId]; + switch (foundEntry.type) { + case 'component': + const firstStoryId = foundEntry.children.find( + (childId) => index[childId].type === 'story' + ); + if (!firstStoryId) { + // happens when there are only docs in the component + return; + } + payloads.add({ importPath: (index[firstStoryId] as API_StoryEntry).importPath }); + return; + case 'story': { + // this shouldn't happen because we don't visit components' children. + // so we never get to a story directly. + payloads.add({ + importPath: foundEntry.importPath, + stories: [ + { + id: foundEntry.id, + name: foundEntry.name, + }, + ], + }); + return; + } + case 'docs': { + return; + } + default: + foundEntry.children.forEach(findComponents); + } + }; + findComponents(options.entryId); + + const payload: TestingModuleRunRequestPayload = { + providerId: id, + payload: Array.from(payloads), + }; + fullAPI.emit(TESTING_MODULE_RUN_REQUEST, payload); + + return () => api.cancelTestProvider(id); + }, + setTestProviderWatchMode(id, watchMode) { + api.updateTestProviderState(id, { watching: watchMode }); + fullAPI.emit(TESTING_MODULE_WATCH_MODE_REQUEST, { providerId: id, watchMode }); + }, + cancelTestProvider(id) { + api.updateTestProviderState(id, { cancelling: true }); + fullAPI.emit(TESTING_MODULE_CANCEL_TEST_RUN_REQUEST, { providerId: id }); + }, + }; + + const initModule = async () => { + const initialState: TestProviders = Object.fromEntries( + Object.entries(fullAPI.getElements(Addon_TypesEnum.experimental_TEST_PROVIDER)).map( + ([id, config]) => [ + id, + { + ...config, + ...initialTestProviderState, + ...(state?.testProviders?.[id] || {}), + } as TestProviders[0], + ] + ) + ); + + store.setState({ testProviders: initialState }, { persistence: 'session' }); + }; + return { init: initModule, state, api }; +}; diff --git a/code/core/src/manager-api/root.tsx b/code/core/src/manager-api/root.tsx index 1c150b47f4b3..c2784329ab8f 100644 --- a/code/core/src/manager-api/root.tsx +++ b/code/core/src/manager-api/root.tsx @@ -50,6 +50,7 @@ import { noArrayMerge } from './lib/merge'; import type { ModuleFn } from './lib/types'; import * as addons from './modules/addons'; import * as channel from './modules/channel'; +import * as testProviders from './modules/experimental_testmodule'; import * as globals from './modules/globals'; import * as layout from './modules/layout'; import * as notifications from './modules/notifications'; @@ -79,6 +80,7 @@ export type State = layout.SubState & stories.SubState & refs.SubState & notifications.SubState & + testProviders.SubState & version.SubState & url.SubState & shortcuts.SubState & @@ -98,6 +100,7 @@ export type API = addons.SubAPI & globals.SubAPI & layout.SubAPI & notifications.SubAPI & + testProviders.SubAPI & shortcuts.SubAPI & settings.SubAPI & version.SubAPI & @@ -178,6 +181,7 @@ class ManagerProvider extends Component { addons, layout, notifications, + testProviders, settings, shortcuts, stories, diff --git a/code/core/src/manager/components/sidebar/ContextMenu.tsx b/code/core/src/manager/components/sidebar/ContextMenu.tsx new file mode 100644 index 000000000000..76118ffc8711 --- /dev/null +++ b/code/core/src/manager/components/sidebar/ContextMenu.tsx @@ -0,0 +1,121 @@ +import type { ComponentProps, FC, SyntheticEvent } from 'react'; +import React, { useMemo, useState } from 'react'; + +import { TooltipLinkList, WithTooltip } from '@storybook/core/components'; +import { type API_HashEntry, Addon_TypesEnum } from '@storybook/core/types'; +import { EllipsisIcon } from '@storybook/icons'; + +import { type TestProviders } from '@storybook/core/core-events'; +import { useStorybookState } from '@storybook/core/manager-api'; +import type { API } from '@storybook/core/manager-api'; + +import type { Link } from '../../../components/components/tooltip/TooltipLinkList'; +import { StatusButton } from './StatusButton'; +import type { ExcludesNull } from './Tree'; +import { ContextMenu } from './Tree'; + +export const useContextMenu = (context: API_HashEntry, links: Link[], api: API) => { + const [hoverCount, setHoverCount] = useState(0); + const [isOpen, setIsOpen] = useState(false); + + const handlers = useMemo(() => { + return { + onMouseEnter: () => { + setHoverCount((c) => c + 1); + }, + onOpen: (event: SyntheticEvent) => { + event.stopPropagation(); + setIsOpen(true); + }, + onClose: () => { + setIsOpen(false); + }, + }; + }, []); + + /** + * Calculate the providerLinks whenever the user mouses over the container. We use an incrementer, + * instead of a simple boolean to ensure that the links are recalculated + */ + const providerLinks = useMemo(() => { + const testProviders = api.getElements( + Addon_TypesEnum.experimental_TEST_PROVIDER + ) as any as TestProviders; + + if (hoverCount) { + return generateTestProviderLinks(testProviders, context); + } + return []; + }, [api, context, hoverCount]); + + const isRendered = providerLinks.length > 0 || links.length > 0; + + return useMemo(() => { + return { + onMouseEnter: handlers.onMouseEnter, + node: isRendered ? ( + { + if (!visible) { + handlers.onClose(); + } else { + setIsOpen(true); + } + }} + tooltip={({ onHide }) => ( + + )} + > + + + + + ) : null, + }; + }, [context, handlers, isOpen, isRendered, links]); +}; + +/** + * This component re-subscribes to storybook's core state, hence the Live prefix. It is used to + * render the context menu for the sidebar. it self is a tooltip link list that renders the links + * provided to it. In addition to the links, it also renders the test providers. + */ +const LiveContextMenu: FC<{ context: API_HashEntry } & ComponentProps> = ({ + context, + links, + ...rest +}) => { + const { testProviders } = useStorybookState(); + const providerLinks: Link[] = generateTestProviderLinks(testProviders, context); + const groups = Array.isArray(links[0]) ? (links as Link[][]) : [links as Link[]]; + const all = groups.concat([providerLinks]); + + return ; +}; + +export function generateTestProviderLinks( + testProviders: TestProviders, + context: API_HashEntry +): Link[] { + return Object.entries(testProviders) + .map(([testProviderId, state]) => { + if (!state) { + return null; + } + const content = state.sidebarContextMenu?.({ context, state }, ContextMenu); + + if (!content) { + return null; + } + + return { + id: testProviderId, + content, + }; + }) + .filter(Boolean as any as ExcludesNull); +} diff --git a/code/core/src/manager/components/sidebar/FileSearchList.stories.tsx b/code/core/src/manager/components/sidebar/FileSearchList.stories.tsx index 2c5ec140ee86..7388bf3cb4ab 100644 --- a/code/core/src/manager/components/sidebar/FileSearchList.stories.tsx +++ b/code/core/src/manager/components/sidebar/FileSearchList.stories.tsx @@ -5,6 +5,7 @@ import { FileSearchList } from './FileSearchList'; const meta = { component: FileSearchList, + title: 'Sidebar/FileSearchList', args: { onNewStory: fn(), }, diff --git a/code/core/src/manager/components/sidebar/FileSearchListSkeleton.stories.tsx b/code/core/src/manager/components/sidebar/FileSearchListSkeleton.stories.tsx index eaeaca9c0f75..d8d38eeb6881 100644 --- a/code/core/src/manager/components/sidebar/FileSearchListSkeleton.stories.tsx +++ b/code/core/src/manager/components/sidebar/FileSearchListSkeleton.stories.tsx @@ -4,6 +4,7 @@ import { FileSearchListLoadingSkeleton } from './FileSearchListSkeleton'; const meta = { component: FileSearchListLoadingSkeleton, + title: 'Sidebar/FileSearchListLoadingSkeleton', } satisfies Meta; export default meta; diff --git a/code/core/src/manager/components/sidebar/FileSearchModal.stories.tsx b/code/core/src/manager/components/sidebar/FileSearchModal.stories.tsx index a2a10005b887..c9e1790ef118 100644 --- a/code/core/src/manager/components/sidebar/FileSearchModal.stories.tsx +++ b/code/core/src/manager/components/sidebar/FileSearchModal.stories.tsx @@ -8,6 +8,7 @@ import { FileSearchModal } from './FileSearchModal'; const meta = { component: FileSearchModal, + title: 'Sidebar/FileSearchModal', args: { open: true, setError: fn(), diff --git a/code/core/src/manager/components/sidebar/FilterToggle.stories.ts b/code/core/src/manager/components/sidebar/FilterToggle.stories.ts index 6710af80c37c..075b8c94dc9a 100644 --- a/code/core/src/manager/components/sidebar/FilterToggle.stories.ts +++ b/code/core/src/manager/components/sidebar/FilterToggle.stories.ts @@ -4,6 +4,7 @@ import { FilterToggle } from './FilterToggle'; export default { component: FilterToggle, + title: 'Sidebar/FilterToggle', args: { active: false, onClick: fn(), diff --git a/code/core/src/manager/components/sidebar/IconSymbols.stories.tsx b/code/core/src/manager/components/sidebar/IconSymbols.stories.tsx index 76136230d3d8..143c08932d37 100644 --- a/code/core/src/manager/components/sidebar/IconSymbols.stories.tsx +++ b/code/core/src/manager/components/sidebar/IconSymbols.stories.tsx @@ -4,6 +4,7 @@ import { IconSymbols } from './IconSymbols'; const meta = { component: IconSymbols, + title: 'Sidebar/IconSymbols', } satisfies Meta; export default meta; diff --git a/code/core/src/manager/components/sidebar/LegacyRender.tsx b/code/core/src/manager/components/sidebar/LegacyRender.tsx new file mode 100644 index 000000000000..f0e74f460219 --- /dev/null +++ b/code/core/src/manager/components/sidebar/LegacyRender.tsx @@ -0,0 +1,89 @@ +import React from 'react'; + +import { Button } from '@storybook/core/components'; +import { styled } from '@storybook/core/theming'; +import { EyeIcon, PlayHollowIcon, StopAltHollowIcon } from '@storybook/icons'; + +import type { TestProviders } from '@storybook/core/core-events'; +import { useStorybookApi } from '@storybook/core/manager-api'; + +const Info = styled.div({ + display: 'flex', + flexDirection: 'column', + marginLeft: 6, +}); + +const Actions = styled.div({ + display: 'flex', + gap: 6, +}); + +const TitleWrapper = styled.div<{ crashed?: boolean }>(({ crashed, theme }) => ({ + fontSize: theme.typography.size.s1, + fontWeight: crashed ? 'bold' : 'normal', + color: crashed ? theme.color.negativeText : theme.color.defaultText, +})); + +const DescriptionWrapper = styled.div(({ theme }) => ({ + fontSize: theme.typography.size.s1, + color: theme.barTextColor, +})); + +export const LegacyRender = ({ ...state }: TestProviders[keyof TestProviders]) => { + const Description = state.description!; + const Title = state.title!; + const api = useStorybookApi(); + + return ( + <> + + + + </TitleWrapper> + <DescriptionWrapper id="testing-module-description"> + <Description {...state} /> + </DescriptionWrapper> + </Info> + + <Actions> + {state.watchable && ( + <Button + aria-label={`${state.watching ? 'Disable' : 'Enable'} watch mode for ${name}`} + variant="ghost" + padding="small" + active={state.watching} + onClick={() => api.setTestProviderWatchMode(state.id, !state.watching)} + disabled={state.crashed || state.running} + > + <EyeIcon /> + </Button> + )} + {state.runnable && ( + <> + {state.running && state.cancellable ? ( + <Button + aria-label={`Stop ${name}`} + variant="ghost" + padding="small" + onClick={() => api.cancelTestProvider(state.id)} + disabled={state.cancelling} + > + <StopAltHollowIcon /> + </Button> + ) : ( + <Button + aria-label={`Start ${state.name}`} + variant="ghost" + padding="small" + onClick={() => api.runTestProvider(state.id)} + disabled={state.crashed || state.running} + > + <PlayHollowIcon /> + </Button> + )} + </> + )} + </Actions> + </> + ); +}; diff --git a/code/core/src/manager/components/sidebar/Menu.tsx b/code/core/src/manager/components/sidebar/Menu.tsx index 583a2b982616..aef385c2de1d 100644 --- a/code/core/src/manager/components/sidebar/Menu.tsx +++ b/code/core/src/manager/components/sidebar/Menu.tsx @@ -1,7 +1,7 @@ import type { ComponentProps, FC } from 'react'; -import React, { useMemo, useState } from 'react'; +import React, { useState } from 'react'; -import type { Button, TooltipLinkListLink } from '@storybook/core/components'; +import type { Button } from '@storybook/core/components'; import { IconButton, TooltipLinkList, WithTooltip } from '@storybook/core/components'; import { styled } from '@storybook/core/theming'; import { CloseIcon, CogIcon } from '@storybook/icons'; @@ -55,28 +55,11 @@ const MenuButtonGroup = styled.div({ gap: 4, }); -type ClickHandler = TooltipLinkListLink['onClick']; - const SidebarMenuList: FC<{ menu: MenuList; - onHide: () => void; -}> = ({ menu, onHide }) => { - const links = useMemo( - () => - menu.map((group) => - group.map(({ onClick, ...rest }) => ({ - ...rest, - onClick: ((event, item) => { - if (onClick) { - onClick(event, item); - } - onHide(); - }) as ClickHandler, - })) - ), - [menu, onHide] - ); - return <TooltipLinkList links={links} />; + onClick: () => void; +}> = ({ menu, onClick }) => { + return <TooltipLinkList links={menu} onClick={onClick} />; }; export interface SidebarMenuProps { @@ -118,7 +101,7 @@ export const SidebarMenu: FC<SidebarMenuProps> = ({ menu, isHighlighted, onClick <WithTooltip placement="top" closeOnOutsideClick - tooltip={({ onHide }) => <SidebarMenuList onHide={onHide} menu={menu} />} + tooltip={({ onHide }) => <SidebarMenuList onClick={onHide} menu={menu} />} onVisibleChange={setIsTooltipVisible} > <SidebarIconButton diff --git a/code/core/src/manager/components/sidebar/RefIndicator.tsx b/code/core/src/manager/components/sidebar/RefIndicator.tsx index a6f01155a3d4..2a2e54b37dfa 100644 --- a/code/core/src/manager/components/sidebar/RefIndicator.tsx +++ b/code/core/src/manager/components/sidebar/RefIndicator.tsx @@ -21,12 +21,13 @@ import { useStorybookApi } from '@storybook/core/manager-api'; import { transparentize } from 'polished'; +import type { NormalLink } from '../../../components/components/tooltip/TooltipLinkList'; import type { getStateType } from '../../utils/tree'; import type { RefType } from './types'; const { document, window: globalWindow } = global; -export type ClickHandler = TooltipLinkListLink['onClick']; +export type ClickHandler = NormalLink['onClick']; export interface IndicatorIconProps { type: ReturnType<typeof getStateType>; } diff --git a/code/core/src/manager/components/sidebar/Refs.stories.tsx b/code/core/src/manager/components/sidebar/Refs.stories.tsx index a26cad976dcb..a042970beadc 100644 --- a/code/core/src/manager/components/sidebar/Refs.stories.tsx +++ b/code/core/src/manager/components/sidebar/Refs.stories.tsx @@ -1,5 +1,7 @@ import React from 'react'; +import { fn } from '@storybook/test'; + import { ManagerContext } from '@storybook/core/manager-api'; import { standardData as standardHeaderData } from './Heading.stories'; @@ -8,6 +10,15 @@ import { Ref } from './Refs'; import { mockDataset } from './mockdata'; import type { RefType } from './types'; +const managerContext = { + state: { docsOptions: {}, testProviders: {} }, + api: { + on: fn().mockName('api::on'), + off: fn().mockName('api::off'), + getElements: fn(() => ({})), + }, +} as any; + export default { component: Ref, title: 'Sidebar/Refs', @@ -16,7 +27,7 @@ export default { globals: { sb_theme: 'side-by-side' }, decorators: [ (storyFn: any) => ( - <ManagerContext.Provider value={{ state: { docsOptions: {} } } as any}> + <ManagerContext.Provider value={managerContext}> <IconSymbols /> {storyFn()} </ManagerContext.Provider> diff --git a/code/core/src/manager/components/sidebar/Sidebar.stories.tsx b/code/core/src/manager/components/sidebar/Sidebar.stories.tsx index dcc6b06096c2..53f22957f17d 100644 --- a/code/core/src/manager/components/sidebar/Sidebar.stories.tsx +++ b/code/core/src/manager/components/sidebar/Sidebar.stories.tsx @@ -33,6 +33,7 @@ const managerContext: any = { autodocs: 'tag', docsMode: false, }, + testProviders: {}, }, api: { emit: fn().mockName('api::emit'), diff --git a/code/core/src/manager/components/sidebar/Sidebar.tsx b/code/core/src/manager/components/sidebar/Sidebar.tsx index bb3be73f425d..d4784cc8c562 100644 --- a/code/core/src/manager/components/sidebar/Sidebar.tsx +++ b/code/core/src/manager/components/sidebar/Sidebar.tsx @@ -24,7 +24,6 @@ import { Search } from './Search'; import { SearchResults } from './SearchResults'; import { SidebarBottom } from './SidebarBottom'; import { TagsFilter } from './TagsFilter'; -import { TEST_PROVIDER_ID } from './Tree'; import type { CombinedDataset, Selection } from './types'; import { useLastViewed } from './useLastViewed'; diff --git a/code/core/src/manager/components/sidebar/SidebarBottom.stories.tsx b/code/core/src/manager/components/sidebar/SidebarBottom.stories.tsx index 8d7d2c42f488..e09d0cafb62b 100644 --- a/code/core/src/manager/components/sidebar/SidebarBottom.stories.tsx +++ b/code/core/src/manager/components/sidebar/SidebarBottom.stories.tsx @@ -1,37 +1,68 @@ +import React from 'react'; + import { Addon_TypesEnum } from '@storybook/core/types'; +import type { Meta } from '@storybook/react'; import { fn } from '@storybook/test'; +import { type API, ManagerContext } from '@storybook/core/manager-api'; + import { SidebarBottomBase } from './SidebarBottom'; +const managerContext: any = { + state: { + docsOptions: { + defaultName: 'Docs', + autodocs: 'tag', + docsMode: false, + }, + testProviders: { + 'component-tests': { + type: Addon_TypesEnum.experimental_TEST_PROVIDER, + id: 'component-tests', + title: () => 'Component tests', + description: () => 'Ran 2 seconds ago', + runnable: true, + watchable: true, + }, + 'visual-tests': { + type: Addon_TypesEnum.experimental_TEST_PROVIDER, + id: 'visual-tests', + title: () => 'Visual tests', + description: () => 'Not run', + runnable: true, + }, + }, + }, + api: { + on: fn().mockName('api::on'), + off: fn().mockName('api::off'), + updateTestProviderState: fn(), + }, +}; + export default { component: SidebarBottomBase, + title: 'Sidebar/SidebarBottom', args: { isDevelopment: true, + api: { + on: fn(), + off: fn(), clearNotification: fn(), + updateTestProviderState: fn(), emit: fn(), experimental_setFilter: fn(), getChannel: fn(), - getElements: fn(() => ({ - 'component-tests': { - type: Addon_TypesEnum.experimental_TEST_PROVIDER, - id: 'component-tests', - title: () => 'Component tests', - description: () => 'Ran 2 seconds ago', - runnable: true, - watchable: true, - }, - 'visual-tests': { - type: Addon_TypesEnum.experimental_TEST_PROVIDER, - id: 'visual-tests', - title: () => 'Visual tests', - description: () => 'Not run', - runnable: true, - }, - })), - }, + getElements: fn(() => ({})), + } as any as API, }, -}; + decorators: [ + (storyFn) => ( + <ManagerContext.Provider value={managerContext}>{storyFn()}</ManagerContext.Provider> + ), + ], +} as Meta<typeof SidebarBottomBase>; export const Errors = { args: { diff --git a/code/core/src/manager/components/sidebar/SidebarBottom.tsx b/code/core/src/manager/components/sidebar/SidebarBottom.tsx index f6aae845bee3..9eea4a4855b8 100644 --- a/code/core/src/manager/components/sidebar/SidebarBottom.tsx +++ b/code/core/src/manager/components/sidebar/SidebarBottom.tsx @@ -105,17 +105,7 @@ export const SidebarBottomBase = ({ const wrapperRef = useRef<HTMLDivElement | null>(null); const [warningsActive, setWarningsActive] = useState(false); const [errorsActive, setErrorsActive] = useState(false); - const [testProviders, setTestProviders] = useState<TestProviders>(() => { - let sessionState: TestProviders = {}; - try { - sessionState = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || '{}'); - } catch (_) {} - return Object.fromEntries( - Object.entries(api.getElements(Addon_TypesEnum.experimental_TEST_PROVIDER)).map( - ([id, config]) => [id, { ...config, ...initialTestProviderState, ...sessionState[id] }] - ) - ); - }); + const { testProviders } = useStorybookState(); const warnings = Object.values(status).filter((statusByAddonId) => Object.values(statusByAddonId).some((value) => value?.status === 'warn') @@ -126,55 +116,6 @@ export const SidebarBottomBase = ({ const hasWarnings = warnings.length > 0; const hasErrors = errors.length > 0; - const updateTestProvider = useCallback( - (id: TestProviderId, update: Partial<TestProviderState>) => - setTestProviders((state) => { - const newValue = { ...state, [id]: { ...state[id], ...update } }; - sessionStorage.setItem(STORAGE_KEY, JSON.stringify(newValue)); - return newValue; - }), - [] - ); - - const clearState = useCallback( - ({ providerId }: { providerId: TestProviderId }) => { - updateTestProvider(providerId, { - cancelling: false, - running: true, - failed: false, - crashed: false, - progress: undefined, - }); - api.experimental_updateStatus(providerId, (state = {}) => - Object.fromEntries(Object.keys(state).map((key) => [key, null])) - ); - }, - [api, updateTestProvider] - ); - - const onRunTests = useCallback( - (id: TestProviderId) => { - api.emit(TESTING_MODULE_RUN_ALL_REQUEST, { providerId: id }); - }, - [api] - ); - - const onCancelTests = useCallback( - (id: TestProviderId) => { - updateTestProvider(id, { cancelling: true }); - api.emit(TESTING_MODULE_CANCEL_TEST_RUN_REQUEST, { providerId: id }); - }, - [api, updateTestProvider] - ); - - const onSetWatchMode = useCallback( - (providerId: string, watchMode: boolean) => { - updateTestProvider(providerId, { watching: watchMode }); - api.emit(TESTING_MODULE_WATCH_MODE_REQUEST, { providerId, watchMode }); - }, - [api, updateTestProvider] - ); - useEffect(() => { const spacer = spacerRef.current; const wrapper = wrapperRef.current; @@ -196,15 +137,27 @@ export const SidebarBottomBase = ({ useEffect(() => { const onCrashReport = ({ providerId, ...details }: TestingModuleCrashReportPayload) => { - updateTestProvider(providerId, { details, running: false, crashed: true, watching: false }); + api.updateTestProviderState(providerId, { + details, + running: false, + crashed: true, + watching: false, + }); + }; + + const clearState = ({ providerId }: { providerId: TestProviderId }) => { + api.clearTestProviderState(providerId); + api.experimental_updateStatus(providerId, (state = {}) => + Object.fromEntries(Object.keys(state).map((key) => [key, null])) + ); }; const onProgressReport = ({ providerId, ...result }: TestingModuleProgressReportPayload) => { if (result.status === 'failed') { - updateTestProvider(providerId, { ...result, running: false, failed: true }); + api.updateTestProviderState(providerId, { ...result, running: false, failed: true }); } else { const update = { ...result, running: result.status === 'pending' }; - updateTestProvider(providerId, update); + api.updateTestProviderState(providerId, update); const { mapStatusUpdate, ...state } = testProviders[providerId]; const statusUpdate = mapStatusUpdate?.({ ...state, ...update }); @@ -214,18 +167,18 @@ export const SidebarBottomBase = ({ } }; - api.getChannel()?.on(TESTING_MODULE_CRASH_REPORT, onCrashReport); - api.getChannel()?.on(TESTING_MODULE_RUN_ALL_REQUEST, clearState); - api.getChannel()?.on(TESTING_MODULE_PROGRESS_REPORT, onProgressReport); + api.on(TESTING_MODULE_CRASH_REPORT, onCrashReport); + api.on(TESTING_MODULE_RUN_ALL_REQUEST, clearState); + api.on(TESTING_MODULE_PROGRESS_REPORT, onProgressReport); return () => { - api.getChannel()?.off(TESTING_MODULE_CRASH_REPORT, onCrashReport); - api.getChannel()?.off(TESTING_MODULE_PROGRESS_REPORT, onProgressReport); - api.getChannel()?.off(TESTING_MODULE_RUN_ALL_REQUEST, clearState); + api.off(TESTING_MODULE_CRASH_REPORT, onCrashReport); + api.off(TESTING_MODULE_PROGRESS_REPORT, onProgressReport); + api.off(TESTING_MODULE_RUN_ALL_REQUEST, clearState); }; - }, [api, testProviders, updateTestProvider, clearState]); + }, [api, testProviders]); - const testProvidersArray = Object.values(testProviders); + const testProvidersArray = Object.values(testProviders || {}); if (!hasWarnings && !hasErrors && !testProvidersArray.length && !notifications.length) { return null; } @@ -244,9 +197,6 @@ export const SidebarBottomBase = ({ warningCount: warnings.length, warningsActive, setWarningsActive, - onRunTests, - onCancelTests, - onSetWatchMode, }} /> )} diff --git a/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx b/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx index 4050986a91f1..89236acda68a 100644 --- a/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx @@ -5,6 +5,7 @@ import { TagsFilter } from './TagsFilter'; const meta = { component: TagsFilter, + title: 'Sidebar/TagsFilter', tags: ['haha'], args: { api: { diff --git a/code/core/src/manager/components/sidebar/TagsFilterPanel.stories.tsx b/code/core/src/manager/components/sidebar/TagsFilterPanel.stories.tsx index 999c5f3fdb04..0602d0ed7a43 100644 --- a/code/core/src/manager/components/sidebar/TagsFilterPanel.stories.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilterPanel.stories.tsx @@ -5,6 +5,7 @@ import { TagsFilterPanel } from './TagsFilterPanel'; const meta = { component: TagsFilterPanel, + title: 'Sidebar/TagsFilterPanel', args: { toggleTag: fn(), api: { diff --git a/code/core/src/manager/components/sidebar/TestingModule.stories.tsx b/code/core/src/manager/components/sidebar/TestingModule.stories.tsx index bd24177ebefb..3ad57e354c1a 100644 --- a/code/core/src/manager/components/sidebar/TestingModule.stories.tsx +++ b/code/core/src/manager/components/sidebar/TestingModule.stories.tsx @@ -6,6 +6,7 @@ import { fn, userEvent } from '@storybook/test'; import type { TestProviders } from '@storybook/core/core-events'; +import { ManagerContext } from '../../../manager-api'; import { TestingModule } from './TestingModule'; const baseState = { @@ -23,8 +24,13 @@ const testProviders: TestProviders[keyof TestProviders][] = [ type: Addon_TypesEnum.experimental_TEST_PROVIDER, id: 'component-tests', name: 'Component tests', - title: () => 'Component tests', - description: () => 'Ran 2 seconds ago', + render: () => ( + <> + Component tests + <br /> + Ran 2 seconds ago + </> + ), runnable: true, watchable: true, ...baseState, @@ -33,8 +39,13 @@ const testProviders: TestProviders[keyof TestProviders][] = [ type: Addon_TypesEnum.experimental_TEST_PROVIDER, id: 'visual-tests', name: 'Visual tests', - title: () => 'Visual tests', - description: () => 'Not run', + render: () => ( + <> + Visual tests + <br /> + Not run + </> + ), runnable: true, ...baseState, }, @@ -42,15 +53,30 @@ const testProviders: TestProviders[keyof TestProviders][] = [ type: Addon_TypesEnum.experimental_TEST_PROVIDER, id: 'linting', name: 'Linting', - title: () => 'Linting', - description: () => 'Watching for changes', + render: () => ( + <> + Linting + <br /> + Watching for changes + </> + ), ...baseState, watching: true, }, ]; +const managerContext: any = { + api: { + runTestProvider: fn().mockName('api::runTestProvider'), + cancelTestProvider: fn().mockName('api::cancelTestProvider'), + updateTestProviderState: fn().mockName('api::updateTestProviderState'), + setTestProviderWatchMode: fn().mockName('api::setTestProviderWatchMode'), + }, +}; + const meta = { component: TestingModule, + title: 'Sidebar/TestingModule', args: { testProviders, errorCount: 0, @@ -59,11 +85,11 @@ const meta = { warningCount: 0, warningsActive: false, setWarningsActive: fn(), - onRunTests: fn(), - onCancelTests: fn(), - onSetWatchMode: fn(), }, decorators: [ + (storyFn) => ( + <ManagerContext.Provider value={managerContext}>{storyFn()}</ManagerContext.Provider> + ), (StoryFn) => ( <div style={{ maxWidth: 232 }}> <StoryFn /> @@ -180,8 +206,13 @@ export const Crashed: Story = { testProviders: [ { ...testProviders[0], - title: () => "Component tests didn't complete", - description: () => 'Problems!', + render: () => ( + <> + Component tests didn't complete + <br /> + Problems! + </> + ), crashed: true, }, ...testProviders.slice(1), diff --git a/code/core/src/manager/components/sidebar/TestingModule.tsx b/code/core/src/manager/components/sidebar/TestingModule.tsx index 510fdc24212c..2b3ef24f17a3 100644 --- a/code/core/src/manager/components/sidebar/TestingModule.tsx +++ b/code/core/src/manager/components/sidebar/TestingModule.tsx @@ -2,17 +2,13 @@ import React, { type SyntheticEvent, useEffect, useRef, useState } from 'react'; import { Button, TooltipNote } from '@storybook/core/components'; import { keyframes, styled } from '@storybook/core/theming'; -import { - ChevronSmallUpIcon, - EyeIcon, - PlayAllHollowIcon, - PlayHollowIcon, - StopAltHollowIcon, -} from '@storybook/icons'; +import { ChevronSmallUpIcon, PlayAllHollowIcon } from '@storybook/icons'; import type { TestProviders } from '@storybook/core/core-events'; import { WithTooltip } from '../../../components/components/tooltip/WithTooltip'; +import { useStorybookApi } from '../../../manager-api'; +import { LegacyRender } from './LegacyRender'; const DEFAULT_HEIGHT = 500; @@ -148,43 +144,6 @@ const TestProvider = styled.div({ gap: 6, }); -const Info = styled.div({ - display: 'flex', - flexDirection: 'column', - marginLeft: 6, -}); - -const Actions = styled.div({ - display: 'flex', - gap: 6, -}); - -const TitleWrapper = styled.div<{ crashed?: boolean }>(({ crashed, theme }) => ({ - fontSize: theme.typography.size.s1, - fontWeight: crashed ? 'bold' : 'normal', - color: crashed ? theme.color.negativeText : theme.color.defaultText, -})); - -const DescriptionWrapper = styled.div(({ theme }) => ({ - fontSize: theme.typography.size.s1, - color: theme.barTextColor, -})); - -const DynamicInfo = ({ state }: { state: TestProviders[keyof TestProviders] }) => { - const Description = state.description; - const Title = state.title; - return ( - <Info> - <TitleWrapper crashed={state.crashed} id="testing-module-title"> - <Title {...state} /> - </TitleWrapper> - <DescriptionWrapper id="testing-module-description"> - <Description {...state} /> - </DescriptionWrapper> - </Info> - ); -}; - interface TestingModuleProps { testProviders: TestProviders[keyof TestProviders][]; errorCount: number; @@ -193,9 +152,6 @@ interface TestingModuleProps { warningCount: number; warningsActive: boolean; setWarningsActive: (active: boolean) => void; - onRunTests: (providerId: string) => void; - onCancelTests: (providerId: string) => void; - onSetWatchMode: (providerId: string, watchMode: boolean) => void; } export const TestingModule = ({ @@ -206,10 +162,8 @@ export const TestingModule = ({ warningCount, warningsActive, setWarningsActive, - onRunTests, - onCancelTests, - onSetWatchMode, }: TestingModuleProps) => { + const api = useStorybookApi(); const contentRef = useRef<HTMLDivElement>(null); const [collapsed, setCollapsed] = useState(false); const [maxHeight, setMaxHeight] = useState(DEFAULT_HEIGHT); @@ -243,50 +197,14 @@ export const TestingModule = ({ }} > <Content ref={contentRef}> - {testProviders.map((state) => ( - <TestProvider key={state.id} data-module-id={state.id}> - <DynamicInfo state={state} /> - <Actions> - {state.watchable && ( - <Button - aria-label={`${state.watching ? 'Disable' : 'Enable'} watch mode for ${state.name}`} - variant="ghost" - padding="small" - active={state.watching} - onClick={() => onSetWatchMode(state.id, !state.watching)} - disabled={state.crashed || state.running} - > - <EyeIcon /> - </Button> - )} - {state.runnable && ( - <> - {state.running && state.cancellable ? ( - <Button - aria-label={`Stop ${state.name}`} - variant="ghost" - padding="small" - onClick={() => onCancelTests(state.id)} - disabled={state.cancelling} - > - <StopAltHollowIcon /> - </Button> - ) : ( - <Button - aria-label={`Start ${state.name}`} - variant="ghost" - padding="small" - onClick={() => onRunTests(state.id)} - disabled={state.crashed || state.running} - > - <PlayHollowIcon /> - </Button> - )} - </> - )} - </Actions> - </TestProvider> - ))} + {testProviders.map((state) => { + const { render: Render } = state; + return ( + <TestProvider key={state.id} data-module-id={state.id}> + {Render ? <Render {...state} /> : <LegacyRender {...state} />} + </TestProvider> + ); + })} </Content> </Collapsible> @@ -299,7 +217,7 @@ export const TestingModule = ({ e.stopPropagation(); testProviders .filter((state) => !state.crashed && !state.running && state.runnable) - .forEach(({ id }) => onRunTests(id)); + .forEach(({ id }) => api.runTestProvider(id)); }} disabled={running} > diff --git a/code/core/src/manager/components/sidebar/Tree.stories.tsx b/code/core/src/manager/components/sidebar/Tree.stories.tsx index a8f3a227b2dc..baadd2e60fec 100644 --- a/code/core/src/manager/components/sidebar/Tree.stories.tsx +++ b/code/core/src/manager/components/sidebar/Tree.stories.tsx @@ -2,9 +2,9 @@ import React, { useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; -import { expect, within } from '@storybook/test'; +import { expect, fn, userEvent, within } from '@storybook/test'; -import type { ComponentEntry, IndexHash } from '@storybook/core/manager-api'; +import { type ComponentEntry, type IndexHash, ManagerContext } from '@storybook/core/manager-api'; import { action } from '@storybook/addon-actions'; @@ -12,6 +12,55 @@ import { DEFAULT_REF_ID } from './Sidebar'; import { Tree } from './Tree'; import { index } from './mockdata.large'; +const managerContext: any = { + state: { + docsOptions: { + defaultName: 'Docs', + autodocs: 'tag', + docsMode: false, + }, + testProviders: { + 'component-tests': { + type: 'experimental_TEST_PROVIDER', + id: 'component-tests', + render: () => 'Component tests', + sidebarContextMenu: () => <div>TEST_PROVIDER_CONTEXT_CONTENT</div>, + runnable: true, + watchable: true, + }, + 'visual-tests': { + type: 'experimental_TEST_PROVIDER', + id: 'visual-tests', + render: () => 'Visual tests', + sidebarContextMenu: () => null, + runnable: true, + }, + }, + }, + api: { + on: fn().mockName('api::on'), + off: fn().mockName('api::off'), + emit: fn().mockName('api::emit'), + getElements: fn(() => ({ + 'component-tests': { + type: 'experimental_TEST_PROVIDER', + id: 'component-tests', + render: () => 'Component tests', + sidebarContextMenu: () => <div>TEST_PROVIDER_CONTEXT_CONTENT</div>, + runnable: true, + watchable: true, + }, + 'visual-tests': { + type: 'experimental_TEST_PROVIDER', + id: 'visual-tests', + render: () => 'Visual tests', + sidebarContextMenu: () => null, + runnable: true, + }, + })), + }, +}; + const meta = { component: Tree, title: 'Sidebar/Tree', @@ -35,6 +84,11 @@ const meta = { }, chromatic: { viewports: [380] }, }, + decorators: [ + (storyFn) => ( + <ManagerContext.Provider value={managerContext}>{storyFn()}</ManagerContext.Provider> + ), + ], } as Meta<typeof Tree>; export default meta; @@ -233,3 +287,41 @@ export const SkipToCanvasLinkFocused: Story = { await expect(link).toBeVisible(); }, }; + +// SkipToCanvas Link only shows on desktop widths +export const WithContextContent: Story = { + ...DocsOnlySingleStoryComponents, + parameters: { + chromatic: { viewports: [1280] }, + viewport: { + options: { + desktop: { + name: 'Desktop', + styles: { + width: '100%', + height: '100%', + }, + }, + }, + }, + }, + globals: { + viewport: { value: 'desktop' }, + }, + play: async ({ canvasElement }) => { + const screen = await within(canvasElement); + + const link = await screen.findByText('TooltipBuildList'); + await userEvent.hover(link); + + const contextButton = await screen.findByTestId('context-menu'); + await userEvent.click(contextButton); + + const body = await within(document.body); + + const tooltip = await body.findByTestId('tooltip'); + + await expect(tooltip).toBeVisible(); + expect(tooltip).toHaveTextContent('TEST_PROVIDER_CONTEXT_CONTENT'); + }, +}; diff --git a/code/core/src/manager/components/sidebar/Tree.tsx b/code/core/src/manager/components/sidebar/Tree.tsx index da9267f6caa4..38f1e7449118 100644 --- a/code/core/src/manager/components/sidebar/Tree.tsx +++ b/code/core/src/manager/components/sidebar/Tree.tsx @@ -1,8 +1,9 @@ -import type { ComponentProps, MutableRefObject } from 'react'; +import type { ComponentProps, FC, MutableRefObject } from 'react'; import React, { useCallback, useMemo, useRef } from 'react'; -import { Button, IconButton, TooltipLinkList, WithTooltip } from '@storybook/core/components'; +import { Button, IconButton, ListItem } from '@storybook/core/components'; import { styled, useTheme } from '@storybook/core/theming'; +import { type API_HashEntry, type API_StatusValue, type StoryId } from '@storybook/core/types'; import { CollapseIcon as CollapseIconSvg, ExpandAltIcon, @@ -11,7 +12,6 @@ import { StatusWarnIcon, SyncIcon, } from '@storybook/icons'; -import type { API_HashEntry, API_StatusValue, StoryId } from '@storybook/types'; import { PRELOAD_ENTRIES } from '@storybook/core/core-events'; import { useStorybookApi } from '@storybook/core/manager-api'; @@ -26,6 +26,7 @@ import type { import { transparentize } from 'polished'; +import type { Link } from '../../../components/components/tooltip/TooltipLinkList'; import { getGroupStatus, getHighestStatus, statusMapping } from '../../utils/status'; import { createId, @@ -35,6 +36,7 @@ import { isStoryHoistable, } from '../../utils/tree'; import { useLayout } from '../layout/LayoutProvider'; +import { useContextMenu } from './ContextMenu'; import { IconSymbols, UseSymbol } from './IconSymbols'; import { StatusButton } from './StatusButton'; import { StatusContext, useStatusSummary } from './StatusContext'; @@ -44,8 +46,7 @@ import type { Highlight, Item } from './types'; import type { ExpandAction, ExpandedState } from './useExpanded'; import { useExpanded } from './useExpanded'; -export const TEST_ADDON_ID = 'storybook/test'; -export const TEST_PROVIDER_ID = `${TEST_ADDON_ID}/test-provider`; +export type ExcludesNull = <T>(x: T | null) => x is T; const Container = styled.div<{ hasOrphans: boolean }>((props) => ({ marginTop: props.hasOrphans ? 20 : 0, @@ -84,6 +85,22 @@ export const LeafNodeStyleWrapper = styled.div(({ theme }) => ({ outline: 'none', }, + '& [data-displayed="off"]': { + visibility: 'hidden', + }, + + '&:hover [data-displayed="off"]': { + visibility: 'visible', + }, + + '& [data-displayed="on"] + *': { + display: 'none', + }, + + '&:hover [data-displayed="off"] + *': { + display: 'none', + }, + '&[data-selected="true"]': { color: theme.color.lightest, background: theme.color.secondary, @@ -139,6 +156,40 @@ interface NodeProps { collapsedData: Record<string, API_HashEntry>; } +const SuccessStatusIcon: FC<ComponentProps<typeof StatusPassIcon>> = (props) => { + const theme = useTheme(); + return <StatusPassIcon {...props} color={theme.color.positive} />; +}; + +const ErrorStatusIcon: FC<ComponentProps<typeof StatusFailIcon>> = (props) => { + const theme = useTheme(); + return <StatusFailIcon {...props} color={theme.color.negative} />; +}; + +const WarnStatusIcon: FC<ComponentProps<typeof StatusWarnIcon>> = (props) => { + const theme = useTheme(); + return <StatusWarnIcon {...props} color={theme.color.warning} />; +}; + +const PendingStatusIcon: FC<ComponentProps<typeof SyncIcon>> = (props) => { + const theme = useTheme(); + return <SyncIcon {...props} size={12} color={theme.color.defaultText} />; +}; + +const StatusIconMap = { + success: <SuccessStatusIcon />, + error: <ErrorStatusIcon />, + warn: <WarnStatusIcon />, + pending: <PendingStatusIcon />, + unknown: null, +}; + +export const ContextMenu = { + ListItem, +}; + +const statusOrder: API_StatusValue[] = ['success', 'error', 'warn', 'pending', 'unknown']; + const Node = React.memo<NodeProps>(function Node({ item, status, @@ -153,26 +204,82 @@ const Node = React.memo<NodeProps>(function Node({ isExpanded, setExpanded, onSelectStoryId, - collapsedData, api, }) { const { isDesktop, isMobile, setMobileMenuOpen } = useLayout(); - const theme = useTheme(); const { counts, statuses } = useStatusSummary(item); if (!isDisplayed) { return null; } + const statusLinks = useMemo<Link[]>(() => { + if (item.type === 'story' || item.type === 'docs') { + return 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, + 'aria-label': `Test status for ${value.title}: ${value.status}`, + icon: StatusIconMap[value.status], + onClick: () => { + onSelectStoryId(item.id); + value.onClick?.(); + }, + })); + } + + if (item.type === 'component' || item.type === 'group') { + const links: Link[] = []; + if (counts.error) { + links.push({ + id: 'errors', + icon: StatusIconMap.error, + title: `${counts.error} ${counts.error === 1 ? 'story' : 'stories'} with errors`, + onClick: () => { + const [firstStoryId, [firstError]] = Object.entries(statuses.error)[0]; + onSelectStoryId(firstStoryId); + firstError.onClick?.(); + }, + }); + } + if (counts.warn) { + links.push({ + id: 'warnings', + icon: StatusIconMap.warn, + title: `${counts.warn} ${counts.warn === 1 ? 'story' : 'stories'} with warnings`, + onClick: () => { + const [firstStoryId, [firstWarning]] = Object.entries(statuses.warn)[0]; + onSelectStoryId(firstStoryId); + firstWarning.onClick?.(); + }, + }); + } + return links; + } + + return []; + }, [ + counts.error, + counts.warn, + item.id, + item.type, + onSelectStoryId, + status, + statuses.error, + statuses.warn, + ]); + const id = createId(item.id, refId); + const contextMenu = useContextMenu(item, statusLinks, api); + if (item.type === 'story' || item.type === 'docs') { const LeafNode = item.type === 'docs' ? DocumentNode : StoryNode; 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} @@ -183,6 +290,7 @@ const Node = React.memo<NodeProps>(function Node({ data-parent-id={item.parent} data-nodetype={item.type === 'docs' ? 'document' : 'story'} data-highlightable={isDisplayed} + onMouseEnter={contextMenu.onMouseEnter} > <LeafNode // @ts-expect-error (non strict) @@ -208,49 +316,17 @@ const Node = React.memo<NodeProps>(function Node({ <a href="#storybook-preview-wrapper">Skip to canvas</a> </SkipToContentLink> )} + {contextMenu.node} {icon ? ( - <WithTooltip - closeOnOutsideClick - closeOnTriggerHidden - onClick={(event) => event.stopPropagation()} - placement="bottom" - tooltip={({ onHide }) => ( - <TooltipLinkList - 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, - 'aria-label': `Test status for ${value.title}: ${value.status}`, - 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?.(); - onHide(); - }, - }))} - /> - )} + <StatusButton + aria-label={`Test status: ${statusValue}`} + role="status" + type="button" + status={statusValue} + selectedItem={isSelected} > - <StatusButton - aria-label={`Test status: ${statusValue}`} - role="status" - type="button" - status={statusValue} - selectedItem={isSelected} - > - {icon} - </StatusButton> - </WithTooltip> + {icon} + </StatusButton> ) : null} </LeafNodeStyleWrapper> ); @@ -302,39 +378,6 @@ const Node = React.memo<NodeProps>(function Node({ 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 ( <LeafNodeStyleWrapper key={id} @@ -342,8 +385,9 @@ const Node = React.memo<NodeProps>(function Node({ data-ref-id={refId} data-item-id={item.id} data-parent-id={item.parent} - data-nodetype={item.type === 'component' ? 'component' : 'group'} + data-nodetype={item.type} data-highlightable={isDisplayed} + onMouseEnter={contextMenu.onMouseEnter} > <BranchNode id={id} @@ -374,19 +418,13 @@ const Node = React.memo<NodeProps>(function Node({ {(item.renderLabel as (i: typeof item, api: API) => React.ReactNode)?.(item, api) || item.name} </BranchNode> + {contextMenu.node} {['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> + <StatusButton type="button" status={itemStatus}> + <svg key="icon" viewBox="0 0 6 6" width="6" height="6" type="dot"> + <UseSymbol type="dot" /> + </svg> + </StatusButton> )} </LeafNodeStyleWrapper> ); @@ -593,6 +631,10 @@ export const Tree = React.memo<{ const isDisplayed = !item.parent || ancestry[itemId].every((a: string) => expanded[a]); + if (isDisplayed === false) { + return null; + } + return ( <Node api={api} diff --git a/code/core/src/types/modules/addons.ts b/code/core/src/types/modules/addons.ts index 2c995322b09d..d01563530bb7 100644 --- a/code/core/src/types/modules/addons.ts +++ b/code/core/src/types/modules/addons.ts @@ -1,11 +1,12 @@ /* eslint-disable @typescript-eslint/naming-convention */ import type { FC, PropsWithChildren, ReactElement, ReactNode } from 'react'; -import type { TestingModuleProgressReportProgress } from '../../core-events'; +import type { ListItem } from '../../components'; +import type { TestProviderConfig, TestingModuleProgressReportProgress } from '../../core-events'; import type { RenderData as RouterData } from '../../router/types'; import type { ThemeVars } from '../../theming/types'; import type { API_SidebarOptions } from './api'; -import type { API_StatusState, API_StatusUpdate } from './api-stories'; +import type { API_HashEntry, API_StatusState, API_StatusUpdate } from './api-stories'; import type { Args, ArgsStoryFn as ArgsStoryFnForFramework, @@ -28,6 +29,7 @@ export type Addon_Types = Exclude< Addon_TypesEnum, | Addon_TypesEnum.experimental_PAGE | Addon_TypesEnum.experimental_SIDEBAR_BOTTOM + | Addon_TypesEnum.experimental_TEST_PROVIDER | Addon_TypesEnum.experimental_SIDEBAR_TOP >; @@ -329,7 +331,7 @@ export type Addon_Type = | Addon_WrapperType | Addon_SidebarBottomType | Addon_SidebarTopType - | Addon_TestProviderType; + | Addon_TestProviderType<Addon_TestProviderState>; export interface Addon_BaseType { /** * The title of the addon. This can be a simple string, but it can also be a @@ -472,8 +474,18 @@ export interface Addon_TestProviderType< /** The unique id of the test provider. */ id: string; name: string; - title: (state: Addon_TestProviderState<Details>) => ReactNode; - description: (state: Addon_TestProviderState<Details>) => ReactNode; + /** @deprecated Use render instead */ + title?: (state: TestProviderConfig & Addon_TestProviderState<Details>) => ReactNode; + /** @deprecated Use render instead */ + description?: (state: TestProviderConfig & Addon_TestProviderState<Details>) => ReactNode; + render?: (state: TestProviderConfig & Addon_TestProviderState<Details>) => ReactNode; + sidebarContextMenu?: ( + options: { + context: API_HashEntry; + state: Addon_TestProviderState<Details>; + }, + components: { ListItem: typeof ListItem } + ) => ReactNode; mapStatusUpdate?: ( state: Addon_TestProviderState<Details> ) => API_StatusUpdate | ((state: API_StatusState) => API_StatusUpdate); @@ -511,7 +523,7 @@ export interface Addon_TypesMapping extends Record<Addon_TypeBaseNames, Addon_Ba [Addon_TypesEnum.experimental_PAGE]: Addon_PageType; [Addon_TypesEnum.experimental_SIDEBAR_BOTTOM]: Addon_SidebarBottomType; [Addon_TypesEnum.experimental_SIDEBAR_TOP]: Addon_SidebarTopType; - [Addon_TypesEnum.experimental_TEST_PROVIDER]: Addon_TestProviderType; + [Addon_TypesEnum.experimental_TEST_PROVIDER]: Addon_TestProviderType<Addon_TestProviderState>; } export type Addon_Loader<API> = (api: API) => void; diff --git a/code/lib/blocks/src/components/ArgsTable/ArgsTable.stories.tsx b/code/lib/blocks/src/components/ArgsTable/ArgsTable.stories.tsx index ab867ff74b5c..8d44e5f7cb43 100644 --- a/code/lib/blocks/src/components/ArgsTable/ArgsTable.stories.tsx +++ b/code/lib/blocks/src/components/ArgsTable/ArgsTable.stories.tsx @@ -90,10 +90,14 @@ export const SectionsAndSubsections = { export const SubsectionsOnly = { args: { rows: { - a: { ...stringType, table: { ...stringType.table, ...componentSubsection } }, + a: { ...stringType, key: 'stringA', table: { ...stringType.table, ...componentSubsection } }, b: { ...numberType, table: { ...stringType.table, ...componentSubsection } }, - c: { ...stringType, table: { ...stringType.table, ...componentSubsection } }, - d: { ...stringType, table: { ...stringType.table, ...htmlElementSubsection } }, + c: { ...stringType, key: 'stringB', table: { ...stringType.table, ...componentSubsection } }, + d: { + ...stringType, + key: 'stringC', + table: { ...stringType.table, ...htmlElementSubsection }, + }, }, }, }; @@ -106,14 +110,14 @@ export const AllControls = { color: ArgRow.Color.args.row, date: ArgRow.Date.args.row, string: ArgRow.String.args.row, - number: ArgRow.Number.args.row, - range: ArgRow.Number.args.row, - radio: ArgRow.Radio.args.row, - inlineRadio: ArgRow.InlineRadio.args.row, - check: ArgRow.Check.args.row, - inlineCheck: ArgRow.InlineCheck.args.row, - select: ArgRow.Select.args.row, - multiSelect: ArgRow.MultiSelect.args.row, + number: { ...ArgRow.Number.args.row, key: 'number' }, + range: { ...ArgRow.Number.args.row, key: 'range' }, + radio: { ...ArgRow.Radio.args.row, key: 'radio' }, + inlineRadio: { ...ArgRow.InlineRadio.args.row, key: 'inlineRadio' }, + check: { ...ArgRow.Check.args.row, key: 'check' }, + inlineCheck: { ...ArgRow.InlineCheck.args.row, key: 'inlineCheck' }, + select: { ...ArgRow.Select.args.row, key: 'select' }, + multiSelect: { ...ArgRow.MultiSelect.args.row, key: 'multiSelect' }, object: ArgRow.ObjectOf.args.row, func: ArgRow.Func.args.row, }, diff --git a/code/lib/blocks/src/examples/Button.stories.tsx b/code/lib/blocks/src/examples/Button.stories.tsx index 31f28a568c51..c22d141f541c 100644 --- a/code/lib/blocks/src/examples/Button.stories.tsx +++ b/code/lib/blocks/src/examples/Button.stories.tsx @@ -137,4 +137,5 @@ export const ErrorStory: Story = { parameters: { chromatic: { disable: true }, }, + tags: ['!test', '!vitest'], };