diff --git a/code/addons/test/src/components/Description.tsx b/code/addons/test/src/components/Description.tsx new file mode 100644 index 000000000000..aa7365a007e8 --- /dev/null +++ b/code/addons/test/src/components/Description.tsx @@ -0,0 +1,55 @@ +import React from 'react'; + +import { Link as LinkComponent } from 'storybook/internal/components'; +import { type TestProviderConfig, type TestProviderState } from 'storybook/internal/core-events'; +import { styled } from 'storybook/internal/theming'; + +import { RelativeTime } from './RelativeTime'; + +export const DescriptionStyle = styled.div(({ theme }) => ({ + fontSize: theme.typography.size.s1, + color: theme.barTextColor, +})); + +export function Description({ + errorMessage, + setIsModalOpen, + state, +}: { + state: TestProviderConfig & TestProviderState; + errorMessage: string; + setIsModalOpen: React.Dispatch>; +}) { + 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 = ( + <> + { + setIsModalOpen(true); + }} + > + {state.error?.name || 'View full error'} + + + ); + } else if (state.progress?.finishedAt) { + description = ( + + ); + } else if (state.watching) { + description = 'Watching for file changes'; + } + return {description}; +} diff --git a/code/addons/test/src/components/RelativeTime.tsx b/code/addons/test/src/components/RelativeTime.tsx index d643960b06ed..fa9e7cf6d549 100644 --- a/code/addons/test/src/components/RelativeTime.tsx +++ b/code/addons/test/src/components/RelativeTime.tsx @@ -1,6 +1,23 @@ import { useEffect, useState } from 'react'; -import { getRelativeTimeString } from '../manager'; +export function getRelativeTimeString(date: Date): string { + const delta = Math.round((date.getTime() - Date.now()) / 1000); + const cutoffs = [60, 3600, 86400, 86400 * 7, 86400 * 30, 86400 * 365, Infinity]; + const units: Intl.RelativeTimeFormatUnit[] = [ + 'second', + 'minute', + 'hour', + 'day', + 'week', + 'month', + 'year', + ]; + + const unitIndex = cutoffs.findIndex((cutoff) => cutoff > Math.abs(delta)); + const divisor = unitIndex ? cutoffs[unitIndex - 1] : 1; + const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }); + return rtf.format(Math.floor(delta / divisor), units[unitIndex]); +} export const RelativeTime = ({ timestamp, testCount }: { timestamp: Date; testCount: number }) => { const [relativeTimeString, setRelativeTimeString] = useState(null); diff --git a/code/addons/test/src/components/Subnav.tsx b/code/addons/test/src/components/Subnav.tsx index 59564dab2b67..bf9d8436cee0 100644 --- a/code/addons/test/src/components/Subnav.tsx +++ b/code/addons/test/src/components/Subnav.tsx @@ -41,7 +41,7 @@ const StyledSubnav = styled.nav(({ theme }) => ({ paddingLeft: 15, })); -export interface SubnavProps { +interface SubnavProps { controls: Controls; controlStates: ControlStates; status: Call['status']; @@ -64,7 +64,7 @@ const Note = styled(TooltipNote)(({ theme }) => ({ fontFamily: theme.typography.fonts.base, })); -export const StyledIconButton = styled(IconButton as any)(({ theme }) => ({ +const StyledIconButton = styled(IconButton)(({ theme }) => ({ color: theme.textMutedColor, margin: '0 3px', })); diff --git a/code/addons/test/src/components/TestProviderRender.stories.tsx b/code/addons/test/src/components/TestProviderRender.stories.tsx new file mode 100644 index 000000000000..4182152ecefe --- /dev/null +++ b/code/addons/test/src/components/TestProviderRender.stories.tsx @@ -0,0 +1,158 @@ +import React from 'react'; + +import type { TestProviderConfig, TestProviderState } from 'storybook/internal/core-events'; +import { ManagerContext } from 'storybook/internal/manager-api'; +import { styled } from 'storybook/internal/theming'; +import { Addon_TypesEnum } from 'storybook/internal/types'; + +import type { Meta, StoryObj } from '@storybook/react'; +import { fn, within } from '@storybook/test'; + +import type { Config, Details } from '../constants'; +import { TestProviderRender } from './TestProviderRender'; + +type Story = StoryObj; +const managerContext: any = { + state: { + testProviders: { + 'test-provider-id': { + id: 'test-provider-id', + name: 'Test Provider', + type: Addon_TypesEnum.experimental_TEST_PROVIDER, + }, + }, + }, + api: { + getDocsUrl: fn().mockName('api::getDocsUrl'), + emit: fn().mockName('api::emit'), + updateTestProviderState: fn().mockName('api::updateTestProviderState'), + }, +}; + +const config: TestProviderConfig = { + id: 'test-provider-id', + name: 'Test Provider', + type: Addon_TypesEnum.experimental_TEST_PROVIDER, + runnable: true, + watchable: true, +}; + +const baseState: TestProviderState = { + cancellable: true, + cancelling: false, + crashed: false, + error: null, + failed: false, + running: false, + watching: false, + config: { + a11y: false, + coverage: false, + }, + details: { + testResults: [ + { + endTime: 0, + startTime: 0, + status: 'passed', + message: 'All tests passed', + results: [ + { + storyId: 'story-id', + status: 'success', + duration: 100, + testRunId: 'test-run-id', + }, + ], + }, + ], + }, +}; + +const Content = styled.div({ + padding: '12px 6px', + display: 'flex', + flexDirection: 'column', + gap: '12px', +}); + +export default { + title: 'TestProviderRender', + component: TestProviderRender, + args: { + state: { + ...config, + ...baseState, + }, + api: managerContext.api, + }, + decorators: [ + (StoryFn) => ( + + + + ), + (StoryFn) => ( + + + + ), + ], +} as Meta; + +export const Default: Story = { + args: { + state: { + ...config, + ...baseState, + }, + }, +}; + +export const Running: Story = { + args: { + state: { + ...config, + ...baseState, + running: true, + }, + }, +}; + +export const EnableA11y: Story = { + args: { + state: { + ...config, + ...baseState, + details: { + testResults: [], + }, + config: { + a11y: true, + coverage: false, + }, + }, + }, +}; + +export const EnableEditing: Story = { + args: { + state: { + ...config, + ...baseState, + config: { + a11y: true, + coverage: false, + }, + details: { + testResults: [], + }, + }, + }, + + play: async ({ canvasElement }) => { + const screen = within(canvasElement); + + screen.getByLabelText('Edit').click(); + }, +}; diff --git a/code/addons/test/src/components/TestProviderRender.tsx b/code/addons/test/src/components/TestProviderRender.tsx new file mode 100644 index 000000000000..9e7534472774 --- /dev/null +++ b/code/addons/test/src/components/TestProviderRender.tsx @@ -0,0 +1,182 @@ +import React, { type FC, Fragment, useCallback, useRef, useState } from 'react'; + +import { Button } from 'storybook/internal/components'; +import { + TESTING_MODULE_CONFIG_CHANGE, + type TestProviderConfig, + type TestProviderState, + type TestingModuleConfigChangePayload, +} from 'storybook/internal/core-events'; +import type { API } from 'storybook/internal/manager-api'; +import { styled } from 'storybook/internal/theming'; + +import { EditIcon, EyeIcon, PlayHollowIcon, StopAltHollowIcon } from '@storybook/icons'; + +import { type Config, type Details, TEST_PROVIDER_ID } from '../constants'; +import { Description } from './Description'; +import { GlobalErrorModal } from './GlobalErrorModal'; + +const Info = styled.div({ + display: 'flex', + flexDirection: 'column', + marginLeft: 6, +}); + +const Title = styled.div<{ crashed?: boolean }>(({ crashed, theme }) => ({ + fontSize: theme.typography.size.s1, + fontWeight: crashed ? 'bold' : 'normal', + color: crashed ? theme.color.negativeText : theme.color.defaultText, +})); + +const Actions = styled.div({ + display: 'flex', + gap: 6, +}); + +const Head = styled.div({ + display: 'flex', + justifyContent: 'space-between', + gap: 6, +}); + +export const TestProviderRender: FC<{ + api: API; + state: TestProviderConfig & TestProviderState; +}> = ({ state, api }) => { + const [isModalOpen, setIsModalOpen] = useState(false); + + const title = state.crashed || state.failed ? 'Component tests failed' : 'Component tests'; + const errorMessage = state.error?.message; + + const [config, changeConfig] = useConfig( + state.id, + state.config || { a11y: false, coverage: false }, + api + ); + + const [isEditing, setIsEditing] = useState(false); + + return ( + + + + + {title} + + + + + + + {state.watchable && ( + + )} + {state.runnable && ( + <> + {state.running && state.cancellable ? ( + + ) : ( + + )} + + )} + + + + {!isEditing ? ( + + {Object.entries(config).map(([key, value]) => ( +
+ {key}: {value ? 'ON' : 'OFF'} +
+ ))} +
+ ) : ( + + {Object.entries(config).map(([key, value]) => ( +
{ + changeConfig({ [key]: !value }); + }} + > + {key}: {value ? 'ON' : 'OFF'} +
+ ))} +
+ )} + + { + setIsModalOpen(false); + }} + onRerun={() => { + setIsModalOpen(false); + api.runTestProvider(TEST_PROVIDER_ID); + }} + /> +
+ ); +}; + +function useConfig(id: string, config: Config, api: API) { + const data = useRef(config); + data.current = config || { + a11y: false, + coverage: false, + }; + + const changeConfig = useCallback( + (update: Partial) => { + const newConfig = { + ...data.current, + ...update, + }; + api.updateTestProviderState(id, { + config: newConfig, + }); + api.emit(TESTING_MODULE_CONFIG_CHANGE, { + providerId: id, + config: newConfig, + } as TestingModuleConfigChangePayload); + }, + [api, id] + ); + + return [data.current, changeConfig] as const; +} diff --git a/code/addons/test/src/constants.ts b/code/addons/test/src/constants.ts index b1c1808db1a4..838594e212a3 100644 --- a/code/addons/test/src/constants.ts +++ b/code/addons/test/src/constants.ts @@ -1,3 +1,5 @@ +import type { TestResult } from './node/reporter'; + export const ADDON_ID = 'storybook/test'; export const TEST_PROVIDER_ID = `${ADDON_ID}/test-provider`; export const PANEL_ID = `${ADDON_ID}/panel`; @@ -7,3 +9,12 @@ export const TUTORIAL_VIDEO_LINK = 'https://youtu.be/Waht9qq7AoA'; export const DOCUMENTATION_LINK = 'writing-tests/test-addon'; export const DOCUMENTATION_DISCREPANCY_LINK = `${DOCUMENTATION_LINK}#what-happens-when-there-are-different-test-results-in-multiple-environments`; export const DOCUMENTATION_FATAL_ERROR_LINK = `${DOCUMENTATION_LINK}#what-happens-if-vitest-itself-has-an-error`; + +export interface Config { + coverage: boolean; + a11y: boolean; +} + +export type Details = { + testResults: TestResult[]; +}; diff --git a/code/addons/test/src/manager.tsx b/code/addons/test/src/manager.tsx index 8f1888b30bf0..dfe729688dd7 100644 --- a/code/addons/test/src/manager.tsx +++ b/code/addons/test/src/manager.tsx @@ -1,9 +1,8 @@ -import React, { useState } from 'react'; +import React from 'react'; -import { AddonPanel, Button, Link as LinkComponent } from 'storybook/internal/components'; +import { AddonPanel } from 'storybook/internal/components'; import type { Combo } 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,15 +10,11 @@ import { Addon_TypesEnum, } from 'storybook/internal/types'; -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'; +import { TestProviderRender } from './components/TestProviderRender'; +import { ADDON_ID, type Config, type Details, PANEL_ID, TEST_PROVIDER_ID } from './constants'; const statusMap: Record = { failed: 'error', @@ -27,47 +22,6 @@ const statusMap: Record = { pending: 'pending', }; -export function getRelativeTimeString(date: Date): string { - const delta = Math.round((date.getTime() - Date.now()) / 1000); - const cutoffs = [60, 3600, 86400, 86400 * 7, 86400 * 30, 86400 * 365, Infinity]; - const units: Intl.RelativeTimeFormatUnit[] = [ - 'second', - 'minute', - 'hour', - 'day', - 'week', - 'month', - 'year', - ]; - - const unitIndex = cutoffs.findIndex((cutoff) => cutoff > Math.abs(delta)); - const divisor = unitIndex ? cutoffs[unitIndex - 1] : 1; - const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }); - return rtf.format(Math.floor(delta / divisor), units[unitIndex]); -} - -const Info = styled.div({ - display: 'flex', - flexDirection: 'column', - marginLeft: 6, -}); - -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, -})); - -const Description = styled.div(({ theme }) => ({ - fontSize: theme.typography.size.s1, - color: theme.barTextColor, -})); - -const Actions = styled.div({ - display: 'flex', - gap: 6, -}); - addons.register(ADDON_ID, (api) => { const storybookBuilder = (globalThis as any).STORYBOOK_BUILDER || ''; if (storybookBuilder.includes('vite')) { @@ -93,106 +47,7 @@ addons.register(ADDON_ID, (api) => { return ; }, - render: (state) => { - const [isModalOpen, setIsModalOpen] = useState(false); - - 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 = ( - <> - { - setIsModalOpen(true); - }} - > - {state.error?.name || 'View full error'} - - - ); - } else if (state.progress?.finishedAt) { - description = ( - - ); - } else if (state.watching) { - description = 'Watching for file changes'; - } - - return ( - <> - - - {title} - - {description} - - - - {state.watchable && ( - - )} - {state.runnable && ( - <> - {state.running && state.cancellable ? ( - - ) : ( - - )} - - )} - - - { - setIsModalOpen(false); - }} - onRerun={() => { - setIsModalOpen(false); - api.runTestProvider(TEST_PROVIDER_ID); - }} - /> - - ); - }, + render: (state) => , mapStatusUpdate: (state) => Object.fromEntries( @@ -218,9 +73,7 @@ addons.register(ADDON_ID, (api) => { .filter(Boolean) ) ), - } as Addon_TestProviderType<{ - testResults: TestResult[]; - }>); + } as Addon_TestProviderType); } const filter = ({ state }: Combo) => { diff --git a/code/addons/test/src/node/test-manager.ts b/code/addons/test/src/node/test-manager.ts index 8d9a33a21e68..624916772056 100644 --- a/code/addons/test/src/node/test-manager.ts +++ b/code/addons/test/src/node/test-manager.ts @@ -1,10 +1,12 @@ import type { Channel } from 'storybook/internal/channels'; import { TESTING_MODULE_CANCEL_TEST_RUN_REQUEST, + TESTING_MODULE_CONFIG_CHANGE, TESTING_MODULE_PROGRESS_REPORT, TESTING_MODULE_RUN_REQUEST, TESTING_MODULE_WATCH_MODE_REQUEST, type TestingModuleCancelTestRunRequestPayload, + type TestingModuleConfigChangePayload, type TestingModuleProgressReportPayload, type TestingModuleRunRequestPayload, type TestingModuleWatchModeRequestPayload, @@ -28,6 +30,7 @@ export class TestManager { this.vitestManager = new VitestManager(channel, this); this.channel.on(TESTING_MODULE_RUN_REQUEST, this.handleRunRequest.bind(this)); + this.channel.on(TESTING_MODULE_CONFIG_CHANGE, this.handleConfigChange.bind(this)); this.channel.on(TESTING_MODULE_WATCH_MODE_REQUEST, this.handleWatchModeRequest.bind(this)); this.channel.on(TESTING_MODULE_CANCEL_TEST_RUN_REQUEST, this.handleCancelRequest.bind(this)); @@ -40,7 +43,15 @@ export class TestManager { await this.vitestManager.startVitest(watchMode); } + async handleConfigChange(payload: TestingModuleConfigChangePayload) { + // TODO do something with the config + const config = payload.config; + } + async handleWatchModeRequest(payload: TestingModuleWatchModeRequestPayload) { + // TODO do something with the config + const config = payload.config; + try { if (payload.providerId !== TEST_PROVIDER_ID) { return; diff --git a/code/core/src/core-events/data/testing-module.ts b/code/core/src/core-events/data/testing-module.ts index 48544c8669d4..80edea66aa64 100644 --- a/code/core/src/core-events/data/testing-module.ts +++ b/code/core/src/core-events/data/testing-module.ts @@ -4,7 +4,10 @@ type DateNow = number; export type TestProviderId = Addon_TestProviderType['id']; export type TestProviderConfig = Addon_TestProviderType; -export type TestProviderState = Addon_TestProviderState; +export type TestProviderState< + Details extends { [key: string]: any } = NonNullable, + Config extends { [key: string]: any } = NonNullable, +> = Addon_TestProviderState; export type TestProviders = Record; @@ -13,6 +16,7 @@ export type TestingModuleRunRequestPayload = { // TODO: Avoid needing to do a fetch request server-side to retrieve the index indexUrl: string; // e.g. http://localhost:6006/index.json storyIds?: string[]; // ['button--primary', 'button--secondary'] + config?: TestProviderState['config']; }; export type TestingModuleProgressReportPayload = @@ -70,4 +74,10 @@ export type TestingModuleCancelTestRunResponsePayload = export type TestingModuleWatchModeRequestPayload = { providerId: TestProviderId; watchMode: boolean; + config?: TestProviderState['config']; +}; + +export type TestingModuleConfigChangePayload = { + providerId: TestProviderId; + config: TestProviderState['config']; }; diff --git a/code/core/src/core-events/index.ts b/code/core/src/core-events/index.ts index f0b09f946607..29c1365d3889 100644 --- a/code/core/src/core-events/index.ts +++ b/code/core/src/core-events/index.ts @@ -91,6 +91,7 @@ enum events { TESTING_MODULE_CANCEL_TEST_RUN_REQUEST = 'testingModuleCancelTestRunRequest', TESTING_MODULE_CANCEL_TEST_RUN_RESPONSE = 'testingModuleCancelTestRunResponse', TESTING_MODULE_WATCH_MODE_REQUEST = 'testingModuleWatchModeRequest', + TESTING_MODULE_CONFIG_CHANGE = 'testingModuleConfigChange', } // Enables: `import Events from ...` @@ -160,6 +161,7 @@ export const { TESTING_MODULE_CANCEL_TEST_RUN_REQUEST, TESTING_MODULE_CANCEL_TEST_RUN_RESPONSE, TESTING_MODULE_WATCH_MODE_REQUEST, + TESTING_MODULE_CONFIG_CHANGE, } = events; export * from './data/create-new-story'; diff --git a/code/core/src/manager-api/modules/experimental_testmodule.ts b/code/core/src/manager-api/modules/experimental_testmodule.ts index fd5fb5e724f3..a6a0eff1d376 100644 --- a/code/core/src/manager-api/modules/experimental_testmodule.ts +++ b/code/core/src/manager-api/modules/experimental_testmodule.ts @@ -8,6 +8,7 @@ import { type TestProviderState, type TestProviders, type TestingModuleRunRequestPayload, + type TestingModuleWatchModeRequestPayload, } from '@storybook/core/core-events'; import invariant from 'tiny-invariant'; @@ -19,6 +20,7 @@ export type SubState = { }; const initialTestProviderState: TestProviderState = { + config: {} as { [key: string]: any }, details: {} as { [key: string]: any }, cancellable: false, cancelling: false, @@ -79,13 +81,17 @@ export const init: ModuleFn = ({ store, fullAPI }) => { const index = store.getState().index; invariant(index, 'The index is currently unavailable'); + const provider = store.getState().testProviders[id]; + const indexUrl = new URL('index.json', window.location.href).toString(); if (!options?.entryId) { const payload: TestingModuleRunRequestPayload = { providerId: id, indexUrl, + config: provider.config, }; + fullAPI.emit(TESTING_MODULE_RUN_REQUEST, payload); return () => api.cancelTestProvider(id); } @@ -107,13 +113,19 @@ export const init: ModuleFn = ({ store, fullAPI }) => { providerId: id, indexUrl, storyIds: findStories(options.entryId), + config: provider.config, }; 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 }); + const config = store.getState().testProviders[id].config; + fullAPI.emit(TESTING_MODULE_WATCH_MODE_REQUEST, { + providerId: id, + watchMode, + config, + } as TestingModuleWatchModeRequestPayload); }, cancelTestProvider(id) { api.updateTestProviderState(id, { cancelling: true }); diff --git a/code/core/src/manager/components/sidebar/SidebarBottom.stories.tsx b/code/core/src/manager/components/sidebar/SidebarBottom.stories.tsx index e09d0cafb62b..898b02f28945 100644 --- a/code/core/src/manager/components/sidebar/SidebarBottom.stories.tsx +++ b/code/core/src/manager/components/sidebar/SidebarBottom.stories.tsx @@ -1,13 +1,40 @@ -import React from 'react'; +import React, { type FC, Fragment, useEffect, useState } from 'react'; import { Addon_TypesEnum } from '@storybook/core/types'; -import type { Meta } from '@storybook/react'; -import { fn } from '@storybook/test'; +import type { Meta, StoryObj } from '@storybook/react'; +import { expect, fn, waitFor, within } from '@storybook/test'; import { type API, ManagerContext } from '@storybook/core/manager-api'; +import { userEvent } from '@storybook/testing-library'; import { SidebarBottomBase } from './SidebarBottom'; +const DynamicHeightDemo: FC = () => { + const [height, setHeight] = useState(100); + + useEffect(() => { + const interval = setInterval(() => { + setHeight((h) => (h === 100 ? 200 : 100)); + }, 2000); + return () => clearInterval(interval); + }, []); + + return ( +
+ CUSTOM CONTENT WITH DYNAMIC HEIGHT +
+ ); +}; + const managerContext: any = { state: { docsOptions: { @@ -57,7 +84,16 @@ export default { getElements: fn(() => ({})), } as any as API, }, + parameters: { + layout: 'fullscreen', + }, decorators: [ + (storyFn) => ( +
+
+ {storyFn()} +
+ ), (storyFn) => ( {storyFn()} ), @@ -92,3 +128,45 @@ export const Both = { }, }, }; + +export const DynamicHeight: StoryObj = { + decorators: [ + (storyFn) => ( + , + runnable: true, + }, + }, + }, + }} + > + {storyFn()} + + ), + ], + play: async ({ canvasElement }) => { + const screen = await within(canvasElement); + + const toggleButton = await screen.getByLabelText('Collapse testing module'); + const content = await screen.findByText('CUSTOM CONTENT WITH DYNAMIC HEIGHT'); + const collapse = await screen.getByTestId('collapse'); + + await expect(content).toBeVisible(); + + await userEvent.click(toggleButton); + + await waitFor(() => expect(collapse.getBoundingClientRect()).toHaveProperty('height', 0)); + + await userEvent.click(toggleButton); + + await waitFor(() => expect(collapse.getBoundingClientRect()).not.toHaveProperty('height', 0)); + }, +}; diff --git a/code/core/src/manager/components/sidebar/SidebarBottom.tsx b/code/core/src/manager/components/sidebar/SidebarBottom.tsx index ccad267e2125..ba64b4e500a5 100644 --- a/code/core/src/manager/components/sidebar/SidebarBottom.tsx +++ b/code/core/src/manager/components/sidebar/SidebarBottom.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { Fragment, useEffect, useRef, useState } from 'react'; import { styled } from '@storybook/core/theming'; import { type API_FilterFunction } from '@storybook/core/types'; @@ -6,7 +6,6 @@ import { type API_FilterFunction } from '@storybook/core/types'; import { TESTING_MODULE_CRASH_REPORT, TESTING_MODULE_PROGRESS_REPORT, - type TestProviderState, type TestingModuleCrashReportPayload, type TestingModuleProgressReportPayload, } from '@storybook/core/core-events'; @@ -25,18 +24,6 @@ const SIDEBAR_BOTTOM_SPACER_ID = 'sidebar-bottom-spacer'; // This ID is used by some integrators to target the (fixed position) sidebar bottom element so it should remain stable. const SIDEBAR_BOTTOM_WRAPPER_ID = 'sidebar-bottom-wrapper'; -const STORAGE_KEY = '@storybook/manager/test-providers'; - -const initialTestProviderState: TestProviderState = { - details: {} as { [key: string]: any }, - cancellable: false, - cancelling: false, - running: false, - watching: false, - failed: false, - crashed: false, -}; - const filterNone: API_FilterFunction = () => true; const filterWarn: API_FilterFunction = ({ status = {} }) => Object.values(status).some((value) => value?.status === 'warn'); @@ -60,6 +47,10 @@ const getFilter = (warningsActive = false, errorsActive = false) => { return filterNone; }; +const Spacer = styled.div({ + pointerEvents: 'none', +}); + const Content = styled.div(({ theme }) => ({ position: 'absolute', bottom: 0, @@ -112,15 +103,13 @@ export const SidebarBottomBase = ({ const hasErrors = errors.length > 0; useEffect(() => { - const spacer = spacerRef.current; - const wrapper = wrapperRef.current; - if (spacer && wrapper) { + if (spacerRef.current && wrapperRef.current) { const resizeObserver = new ResizeObserver(() => { - if (spacer && wrapper) { - spacer.style.height = `${wrapper.clientHeight}px`; + if (spacerRef.current && wrapperRef.current) { + spacerRef.current.style.height = `${wrapperRef.current.scrollHeight}px`; } }); - resizeObserver.observe(wrapper); + resizeObserver.observe(wrapperRef.current); return () => resizeObserver.disconnect(); } }, []); @@ -170,7 +159,8 @@ export const SidebarBottomBase = ({ } return ( -
+ + {isDevelopment && ( @@ -187,13 +177,14 @@ export const SidebarBottomBase = ({ /> )} -
+ ); }; export const SidebarBottom = ({ isDevelopment }: { isDevelopment?: boolean }) => { const api = useStorybookApi(); const { notifications, status } = useStorybookState(); + return ( ({ const Collapsible = styled.div(({ theme }) => ({ overflow: 'hidden', - transition: 'max-height 250ms', + willChange: 'auto', boxShadow: `inset 0 -1px 0 ${theme.appBorderColor}`, })); @@ -164,52 +171,89 @@ export const TestingModule = ({ setWarningsActive, }: TestingModuleProps) => { const api = useStorybookApi(); + const contentRef = useRef(null); - const [collapsed, setCollapsed] = useState(false); + const timeoutRef = useRef>(null); + const [maxHeight, setMaxHeight] = useState(DEFAULT_HEIGHT); + const [isCollapsed, setCollapsed] = useState(false); + const [isChangingCollapse, setChangingCollapse] = useState(false); useEffect(() => { - setMaxHeight(contentRef.current?.offsetHeight || DEFAULT_HEIGHT); + if (contentRef.current) { + setMaxHeight(contentRef.current?.getBoundingClientRect().height || DEFAULT_HEIGHT); + + const resizeObserver = new ResizeObserver(() => { + requestAnimationFrame(() => { + if (contentRef.current && !isCollapsed) { + const height = contentRef.current?.getBoundingClientRect().height || DEFAULT_HEIGHT; + + setMaxHeight(height); + } + }); + }); + resizeObserver.observe(contentRef.current); + return () => resizeObserver.disconnect(); + } + }, [isCollapsed]); + + const toggleCollapsed = useCallback((event: SyntheticEvent) => { + event.stopPropagation(); + setChangingCollapse(true); + setCollapsed((s) => !s); + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = setTimeout(() => { + setChangingCollapse(false); + }, 250); }, []); - const toggleCollapsed = () => { - setMaxHeight(contentRef.current?.offsetHeight || DEFAULT_HEIGHT); - setCollapsed(!collapsed); - }; + const isRunning = testProviders.some((tp) => tp.running); + const isCrashed = testProviders.some((tp) => tp.crashed); + const isFailed = testProviders.some((tp) => tp.failed); + const hasTestProviders = testProviders.length > 0; - const running = testProviders.some((tp) => tp.running); - const crashed = testProviders.some((tp) => tp.crashed); - const failed = testProviders.some((tp) => tp.failed); - const testing = testProviders.length > 0; + if (!hasTestProviders && (!errorCount || !warningCount)) { + return null; + } return ( 0} + running={isRunning} + crashed={isCrashed} + failed={isFailed || errorCount > 0} > - - - {testProviders.map((state) => { - const { render: Render } = state; - return ( - - {Render ? : } - - ); - })} - - + {hasTestProviders && ( + + + {testProviders.map((state) => { + const { render: Render } = state; + return Render ? ( + + + + ) : ( + + + + ); + })} + + + )} - - {testing && ( + + {hasTestProviders && ( )} - {testing && ( + {hasTestProviders && ( , + Config extends { [key: string]: any } = NonNullable, > { type: Addon_TypesEnum.experimental_TEST_PROVIDER; /** The unique id of the test provider. */ id: string; name: string; /** @deprecated Use render instead */ - title?: (state: TestProviderConfig & Addon_TestProviderState
) => ReactNode; + title?: (state: TestProviderConfig & Addon_TestProviderState) => ReactNode; /** @deprecated Use render instead */ - description?: (state: TestProviderConfig & Addon_TestProviderState
) => ReactNode; - render?: (state: TestProviderConfig & Addon_TestProviderState
) => ReactNode; + description?: (state: TestProviderConfig & Addon_TestProviderState) => ReactNode; + render?: (state: TestProviderConfig & Addon_TestProviderState) => ReactNode; sidebarContextMenu?: (options: { context: API_HashEntry; state: Addon_TestProviderState
; @@ -490,21 +491,24 @@ export interface Addon_TestProviderType< watchable?: boolean; } -export type Addon_TestProviderState
> = - Pick & { - progress?: TestingModuleProgressReportProgress; - details: Details; - cancellable: boolean; - cancelling: boolean; - running: boolean; - watching: boolean; - failed: boolean; - crashed: boolean; - error?: { - name: string; - message?: string; - }; +export type Addon_TestProviderState< + Details extends { [key: string]: any } = NonNullable, + Config extends { [key: string]: any } = NonNullable, +> = Pick & { + progress?: TestingModuleProgressReportProgress; + details: Details; + cancellable: boolean; + cancelling: boolean; + running: boolean; + watching: boolean; + failed: boolean; + crashed: boolean; + error?: { + name: string; + message?: string; }; + config?: Config; +}; type Addon_TypeBaseNames = Exclude< Addon_TypesEnum, diff --git a/test-storybooks/portable-stories-kitchen-sink/react/e2e-tests/component-testing.spec.ts b/test-storybooks/portable-stories-kitchen-sink/react/e2e-tests/component-testing.spec.ts index bb76651cc9be..2b60a9314d51 100644 --- a/test-storybooks/portable-stories-kitchen-sink/react/e2e-tests/component-testing.spec.ts +++ b/test-storybooks/portable-stories-kitchen-sink/react/e2e-tests/component-testing.spec.ts @@ -66,7 +66,7 @@ test.describe("component testing", () => { testStoryElement.click(); } - const testingModuleDescription = await page.locator('[data-module-id="storybook/test/test-provider"]').locator('#testing-module-description'); + const testingModuleDescription = await page.locator('#testing-module-description'); await expect(testingModuleDescription).toContainText('Not run'); @@ -126,7 +126,7 @@ test.describe("component testing", () => { await expect(page.locator('#testing-module-title')).toHaveText('Component tests'); - const testingModuleDescription = await page.locator('[data-module-id="storybook/test/test-provider"]').locator('#testing-module-description'); + const testingModuleDescription = await page.locator('#testing-module-description'); await expect(testingModuleDescription).toContainText('Not run');