diff --git a/code/addons/onboarding/src/features/GuidedTour/GuidedTour.tsx b/code/addons/onboarding/src/features/GuidedTour/GuidedTour.tsx index 56bbb75e7d50..8ba6930efb5e 100644 --- a/code/addons/onboarding/src/features/GuidedTour/GuidedTour.tsx +++ b/code/addons/onboarding/src/features/GuidedTour/GuidedTour.tsx @@ -23,7 +23,7 @@ export function GuidedTour({ const theme = useTheme(); useEffect(() => { - let timeout: NodeJS.Timeout; + let timeout: ReturnType; setStepIndex((current) => { const index = steps.findIndex(({ key }) => key === step); diff --git a/code/addons/test/src/components/Description.tsx b/code/addons/test/src/components/Description.tsx index aa7365a007e8..9e7441aca3d3 100644 --- a/code/addons/test/src/components/Description.tsx +++ b/code/addons/test/src/components/Description.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { Link as LinkComponent } from 'storybook/internal/components'; import { type TestProviderConfig, type TestProviderState } from 'storybook/internal/core-events'; @@ -11,6 +11,10 @@ export const DescriptionStyle = styled.div(({ theme }) => ({ color: theme.barTextColor, })); +const PositiveText = styled.span(({ theme }) => ({ + color: theme.color.positiveText, +})); + export function Description({ errorMessage, setIsModalOpen, @@ -20,9 +24,24 @@ export function Description({ errorMessage: string; setIsModalOpen: React.Dispatch>; }) { - let description: string | React.ReactNode = 'Not run'; + const isMounted = React.useRef(false); + const [isUpdated, setUpdated] = React.useState(false); - if (state.running) { + useEffect(() => { + if (isMounted.current) { + setUpdated(true); + const timeout = setTimeout(setUpdated, 2000, false); + return () => { + clearTimeout(timeout); + }; + } + isMounted.current = true; + }, [state.config]); + + let description: string | React.ReactNode = 'Not run'; + if (isUpdated) { + description = Settings updated; + } else if (state.running) { description = state.progress ? `Testing... ${state.progress.numPassedTests}/${state.progress.numTotalTests}` : 'Starting...'; diff --git a/code/addons/test/src/components/TestProviderRender.stories.tsx b/code/addons/test/src/components/TestProviderRender.stories.tsx index 4182152ecefe..13a9e9bd3f35 100644 --- a/code/addons/test/src/components/TestProviderRender.stories.tsx +++ b/code/addons/test/src/components/TestProviderRender.stories.tsx @@ -23,7 +23,9 @@ const managerContext: any = { }, }, api: { - getDocsUrl: fn().mockName('api::getDocsUrl'), + getDocsUrl: fn(({ subpath }) => `https://storybook.js.org/docs/${subpath}`).mockName( + 'api::getDocsUrl' + ), emit: fn().mockName('api::emit'), updateTestProviderState: fn().mockName('api::updateTestProviderState'), }, @@ -98,6 +100,9 @@ export default { ), ], + parameters: { + layout: 'fullscreen', + }, } as Meta; export const Default: Story = { @@ -153,6 +158,6 @@ export const EnableEditing: Story = { play: async ({ canvasElement }) => { const screen = within(canvasElement); - screen.getByLabelText('Edit').click(); + screen.getByLabelText(/Open settings/).click(); }, }; diff --git a/code/addons/test/src/components/TestProviderRender.tsx b/code/addons/test/src/components/TestProviderRender.tsx index 9e7534472774..588523f89ad9 100644 --- a/code/addons/test/src/components/TestProviderRender.tsx +++ b/code/addons/test/src/components/TestProviderRender.tsx @@ -1,20 +1,43 @@ -import React, { type FC, Fragment, useCallback, useRef, useState } from 'react'; +import React, { type FC, useCallback, useRef, useState } from 'react'; -import { Button } from 'storybook/internal/components'; +import { Button, ListItem } 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 { styled, useTheme } from 'storybook/internal/theming'; -import { EditIcon, EyeIcon, PlayHollowIcon, StopAltHollowIcon } from '@storybook/icons'; +import { + AccessibilityIcon, + EditIcon, + EyeIcon, + PlayHollowIcon, + PointerHandIcon, + ShieldIcon, + StopAltHollowIcon, +} from '@storybook/icons'; + +import { isEqual } from 'es-toolkit'; +import { debounce } from 'es-toolkit/compat'; import { type Config, type Details, TEST_PROVIDER_ID } from '../constants'; import { Description } from './Description'; import { GlobalErrorModal } from './GlobalErrorModal'; +import { TestStatusIcon } from './TestStatusIcon'; + +const Container = styled.div({ + display: 'flex', + flexDirection: 'column', +}); + +const Heading = styled.div({ + display: 'flex', + justifyContent: 'space-between', + padding: '8px 2px', + gap: 6, +}); const Info = styled.div({ display: 'flex', @@ -33,32 +56,37 @@ const Actions = styled.div({ gap: 6, }); -const Head = styled.div({ - display: 'flex', - justifyContent: 'space-between', - gap: 6, +const Extras = styled.div({ + marginBottom: 2, +}); + +const Checkbox = styled.input({ + margin: 0, + '&:enabled': { + cursor: 'pointer', + }, }); export const TestProviderRender: FC<{ api: API; state: TestProviderConfig & TestProviderState; }> = ({ state, api }) => { + const [isEditing, setIsEditing] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false); + const theme = useTheme(); - const title = state.crashed || state.failed ? 'Component tests failed' : 'Component tests'; + const title = state.crashed || state.failed ? 'Local tests failed' : 'Run local tests'; const errorMessage = state.error?.message; - const [config, changeConfig] = useConfig( + const [config, updateConfig] = useConfig( + api, state.id, - state.config || { a11y: false, coverage: false }, - api + state.config || { a11y: false, coverage: false } ); - const [isEditing, setIsEditing] = useState(false); - return ( - - + + {title} @@ -68,11 +96,11 @@ export const TestProviderRender: FC<{ <Actions> <Button - aria-label={`Edit`} + aria-label={`${isEditing ? 'Close' : 'Open'} settings for ${state.name}`} variant="ghost" padding="small" active={isEditing} - onClick={() => setIsEditing((v) => !v)} + onClick={() => setIsEditing(!isEditing)} > <EditIcon /> </Button> @@ -105,7 +133,7 @@ export const TestProviderRender: FC<{ aria-label={`Start ${state.name}`} variant="ghost" padding="small" - onClick={() => api.runTestProvider(state.id, {})} + onClick={() => api.runTestProvider(state.id)} disabled={state.crashed || state.running} > <PlayHollowIcon /> @@ -114,29 +142,60 @@ export const TestProviderRender: FC<{ </> )} </Actions> - </Head> - - {!isEditing ? ( - <Fragment> - {Object.entries(config).map(([key, value]) => ( - <div key={key}> - {key}: {value ? 'ON' : 'OFF'} - </div> - ))} - </Fragment> + </Heading> + + {isEditing ? ( + <Extras> + <ListItem + as="label" + title="Component tests" + icon={<PointerHandIcon color={theme.textMutedColor} />} + right={<Checkbox type="checkbox" checked disabled />} + /> + <ListItem + as="label" + title="Coverage" + icon={<ShieldIcon color={theme.textMutedColor} />} + right={ + <Checkbox + type="checkbox" + disabled // TODO: Implement coverage + checked={config.coverage} + onChange={() => updateConfig({ coverage: !config.coverage })} + /> + } + /> + <ListItem + as="label" + title="Accessibility" + icon={<AccessibilityIcon color={theme.textMutedColor} />} + right={ + <Checkbox + type="checkbox" + disabled // TODO: Implement a11y + checked={config.a11y} + onChange={() => updateConfig({ a11y: !config.a11y })} + /> + } + /> + </Extras> ) : ( - <Fragment> - {Object.entries(config).map(([key, value]) => ( - <div - key={key} - onClick={() => { - changeConfig({ [key]: !value }); - }} - > - {key}: {value ? 'ON' : 'OFF'} - </div> - ))} - </Fragment> + <Extras> + <ListItem + title="Component tests" + icon={<TestStatusIcon status="positive" aria-label="status: passed" />} + /> + <ListItem + title="Coverage" + icon={<TestStatusIcon percentage={60} status="warning" aria-label="status: warning" />} + right={`60%`} + /> + <ListItem + title="Accessibility" + icon={<TestStatusIcon status="negative" aria-label="status: failed" />} + right={73} + /> + </Extras> )} <GlobalErrorModal @@ -150,33 +209,35 @@ export const TestProviderRender: FC<{ api.runTestProvider(TEST_PROVIDER_ID); }} /> - </Fragment> + </Container> ); }; -function useConfig(id: string, config: Config, api: API) { - const data = useRef<Config>(config); - data.current = config || { - a11y: false, - coverage: false, - }; +function useConfig(api: API, providerId: string, initialConfig: Config) { + const [currentConfig, setConfig] = useState<Config>(initialConfig); + const lastConfig = useRef(initialConfig); + + const saveConfig = useCallback( + debounce((config: Config) => { + if (!isEqual(config, lastConfig.current)) { + api.updateTestProviderState(providerId, { config }); + api.emit(TESTING_MODULE_CONFIG_CHANGE, { providerId, config }); + lastConfig.current = config; + } + }, 500), + [api, providerId] + ); - const changeConfig = useCallback( + const updateConfig = useCallback( (update: Partial<Config>) => { - const newConfig = { - ...data.current, - ...update, - }; - api.updateTestProviderState(id, { - config: newConfig, + setConfig((value) => { + const updated = { ...value, ...update }; + saveConfig(updated); + return updated; }); - api.emit(TESTING_MODULE_CONFIG_CHANGE, { - providerId: id, - config: newConfig, - } as TestingModuleConfigChangePayload); }, - [api, id] + [saveConfig] ); - return [data.current, changeConfig] as const; + return [currentConfig, updateConfig] as const; } diff --git a/code/addons/test/src/components/TestStatusIcon.stories.tsx b/code/addons/test/src/components/TestStatusIcon.stories.tsx new file mode 100644 index 000000000000..fee0765daf9b --- /dev/null +++ b/code/addons/test/src/components/TestStatusIcon.stories.tsx @@ -0,0 +1,63 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { TestStatusIcon } from './TestStatusIcon'; + +const meta = { + component: TestStatusIcon, +} satisfies Meta<typeof TestStatusIcon>; + +export default meta; + +type Story = StoryObj<typeof meta>; + +export const Unknown: Story = { + args: { + status: 'unknown', + }, +}; + +export const Positive: Story = { + args: { + status: 'positive', + }, +}; + +export const Warning: Story = { + args: { + status: 'warning', + }, +}; + +export const Negative: Story = { + args: { + status: 'negative', + }, +}; + +export const UnknownPercentage: Story = { + args: { + status: 'unknown', + percentage: 50, + }, +}; + +export const PositivePercentage: Story = { + args: { + status: 'positive', + percentage: 60, + }, +}; + +export const WarningPercentage: Story = { + args: { + status: 'warning', + percentage: 40, + }, +}; + +export const NegativePercentage: Story = { + args: { + status: 'negative', + percentage: 30, + }, +}; diff --git a/code/addons/test/src/components/TestStatusIcon.tsx b/code/addons/test/src/components/TestStatusIcon.tsx new file mode 100644 index 000000000000..d61bcf3e7070 --- /dev/null +++ b/code/addons/test/src/components/TestStatusIcon.tsx @@ -0,0 +1,36 @@ +import { styled } from 'storybook/internal/theming'; + +export const TestStatusIcon = styled.div<{ + status: 'positive' | 'warning' | 'negative' | 'unknown'; + percentage?: number; +}>( + ({ percentage }) => ({ + width: percentage ? 12 : 6, + height: percentage ? 12 : 6, + margin: percentage ? 1 : 4, + background: percentage + ? `conic-gradient(var(--status-color) ${percentage}%, var(--status-background) ${percentage + 1}%)` + : 'var(--status-color)', + borderRadius: '50%', + }), + ({ status, theme }) => + status === 'positive' && { + '--status-color': theme.color.positive, + '--status-background': `${theme.color.positive}66`, + }, + ({ status, theme }) => + status === 'warning' && { + '--status-color': theme.color.gold, + '--status-background': `${theme.color.gold}66`, + }, + ({ status, theme }) => + status === 'negative' && { + '--status-color': theme.color.negative, + '--status-background': `${theme.color.negative}66`, + }, + ({ status, theme }) => + status === 'unknown' && { + '--status-color': theme.color.mediumdark, + '--status-background': `${theme.color.mediumdark}66`, + } +); diff --git a/code/addons/test/src/manager.tsx b/code/addons/test/src/manager.tsx index dfe729688dd7..b0b2eeef2402 100644 --- a/code/addons/test/src/manager.tsx +++ b/code/addons/test/src/manager.tsx @@ -35,6 +35,7 @@ addons.register(ADDON_ID, (api) => { runnable: true, watchable: true, name: 'Component tests', + render: (state) => <TestProviderRender api={api} state={state} />, sidebarContextMenu: ({ context, state }) => { if (context.type === 'docs') { @@ -47,8 +48,6 @@ addons.register(ADDON_ID, (api) => { return <ContextMenuItem context={context} state={state} />; }, - render: (state) => <TestProviderRender api={api} state={state} />, - mapStatusUpdate: (state) => Object.fromEntries( (state.details.testResults || []).flatMap((testResult) => diff --git a/code/core/src/components/components/tooltip/ListItem.tsx b/code/core/src/components/components/tooltip/ListItem.tsx index 1752abeb1f8d..2a93287af503 100644 --- a/code/core/src/components/components/tooltip/ListItem.tsx +++ b/code/core/src/components/components/tooltip/ListItem.tsx @@ -150,6 +150,15 @@ const Item = styled.div<ItemProps>( opacity: 1, }, }, + ({ theme, as }) => + as === 'label' && { + '&:has(input:not(:disabled))': { + cursor: 'pointer', + '&:hover': { + background: theme.background.hoverable, + }, + }, + }, ({ disabled }) => disabled && { cursor: 'not-allowed' } ); diff --git a/code/core/src/manager-api/lib/intersect.ts b/code/core/src/manager-api/lib/intersect.ts index 84eae122322d..5918f7c8babf 100644 --- a/code/core/src/manager-api/lib/intersect.ts +++ b/code/core/src/manager-api/lib/intersect.ts @@ -1,6 +1,6 @@ export default <T>(a: T[], b: T[]): T[] => { // no point in intersecting if one of the input is ill-defined - if (!a || !b) { + if (!Array.isArray(a) || !Array.isArray(b) || !a.length || !b.length) { return []; } diff --git a/code/core/src/manager-api/lib/stories.ts b/code/core/src/manager-api/lib/stories.ts index eed677371ef0..59b59f070a10 100644 --- a/code/core/src/manager-api/lib/stories.ts +++ b/code/core/src/manager-api/lib/stories.ts @@ -1,4 +1,5 @@ import type { + API_BaseEntry, API_ComponentEntry, API_DocsEntry, API_GroupEntry, @@ -16,6 +17,7 @@ import type { StoryId, StoryIndexV2, StoryIndexV3, + Tag, } from '@storybook/core/types'; import { sanitize } from '@storybook/csf'; @@ -248,6 +250,7 @@ export const transformStoryIndexToStoriesHash = ( type: 'root', id, name: names[idx], + tags: [], depth: idx, renderLabel, startCollapsed: collapsedRoots.includes(id), @@ -267,6 +270,7 @@ export const transformStoryIndexToStoriesHash = ( type: 'component', id, name: names[idx], + tags: [], parent: paths[idx - 1], depth: idx, renderLabel, @@ -274,14 +278,12 @@ export const transformStoryIndexToStoriesHash = ( children: [childId], }), }); - // merge computes a union of arrays but we want an intersection on this - // specific array property, so it's easier to add it after the merge. - acc[id].tags = intersect(acc[id]?.tags ?? item.tags, item.tags); } else { acc[id] = merge<API_GroupEntry>((acc[id] || {}) as API_GroupEntry, { type: 'group', id, name: names[idx], + tags: [], parent: paths[idx - 1], depth: idx, renderLabel, @@ -295,6 +297,7 @@ export const transformStoryIndexToStoriesHash = ( // Finally add an entry for the docs/story itself acc[item.id] = { type: 'story', + tags: [], ...item, depth: paths.length, parent: paths[paths.length - 1], @@ -313,9 +316,18 @@ export const transformStoryIndexToStoriesHash = ( } acc[item.id] = item; - // Ensure we add the children depth-first *before* inserting any other entries + // Ensure we add the children depth-first *before* inserting any other entries, + // and compute tags from the children put in the accumulator afterwards, once + // they're all known and we can compute a sound intersection. if (item.type === 'root' || item.type === 'group' || item.type === 'component') { item.children.forEach((childId: any) => addItem(acc, storiesHashOutOfOrder[childId])); + + item.tags = item.children.reduce((currentTags: Tag[] | null, childId: any): Tag[] => { + const child = acc[childId]; + + // On the first child, we have nothing to intersect against so we use it as a source of data. + return currentTags === null ? child.tags : intersect(currentTags, child.tags); + }, null); } return acc; } diff --git a/code/core/src/manager-api/modules/experimental_testmodule.ts b/code/core/src/manager-api/modules/experimental_testmodule.ts index 15aec353b32a..289ffe51c81d 100644 --- a/code/core/src/manager-api/modules/experimental_testmodule.ts +++ b/code/core/src/manager-api/modules/experimental_testmodule.ts @@ -21,7 +21,6 @@ export type SubState = { }; const initialTestProviderState: TestProviderState = { - config: {} as { [key: string]: any }, details: {} as { [key: string]: any }, cancellable: false, cancelling: false, diff --git a/code/core/src/manager-api/tests/stories.test.ts b/code/core/src/manager-api/tests/stories.test.ts index 9d3c4433fdec..b652c87cd7a1 100644 --- a/code/core/src/manager-api/tests/stories.test.ts +++ b/code/core/src/manager-api/tests/stories.test.ts @@ -162,6 +162,7 @@ describe('stories API', () => { expect(index!['design-system']).toMatchObject({ type: 'root', name: 'Design System', // root name originates from `kind`, so it gets trimmed + tags: [], }); expect(index!['design-system-some-component']).toMatchObject({ type: 'component', @@ -186,6 +187,7 @@ describe('stories API', () => { title: 'Root/First', name: 'Story 1', importPath: './path/to/root/first.ts', + tags: [], }, ...mockEntries, }, @@ -207,6 +209,7 @@ describe('stories API', () => { type: 'root', id: 'root', children: ['root-first'], + tags: [], }); }); it('sets roots when showRoots = true', () => { @@ -222,6 +225,7 @@ describe('stories API', () => { id: 'a-b--1', title: 'a/b', name: '1', + tags: [], importPath: './a/b.ts', }, }, @@ -233,6 +237,7 @@ describe('stories API', () => { type: 'root', id: 'a', children: ['a-b'], + tags: [], }); expect(index!['a-b']).toMatchObject({ type: 'component', @@ -332,6 +337,76 @@ describe('stories API', () => { tags: ['shared', 'two-specific'], }); }); + + it('intersects story/docs tags to compute tags for root and group entries', () => { + const moduleArgs = createMockModuleArgs({}); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { store } = moduleArgs; + api.setIndex({ + v: 5, + entries: { + 'a-sampleone': { + type: 'story', + id: 'a-sampleone', + title: 'A/SampleOne', + name: '1', + tags: ['shared', 'one-specific'], + importPath: './a.ts', + }, + 'a-sampletwo': { + type: 'story', + id: 'a-sampletwo', + title: 'A/SampleTwo', + name: '2', + tags: ['shared', 'two-specific'], + importPath: './a.ts', + }, + 'a-embedded-othertopic': { + type: 'docs', + id: 'a-embedded-othertopic', + title: 'A/Embedded/OtherTopic', + name: '3', + tags: ['shared', 'embedded-docs-specific', 'other'], + storiesImports: [], + importPath: './embedded/other.mdx', + }, + 'a-embedded-extras': { + type: 'docs', + id: 'a-embedded-extras', + title: 'A/Embedded/Extras', + name: '3', + tags: ['shared', 'embedded-docs-specific', 'extras'], + storiesImports: [], + importPath: './embedded/extras.mdx', + }, + }, + }); + const { index } = store.getState(); + // We need exact key ordering, even if in theory JS doesn't guarantee it + expect(Object.keys(index!)).toEqual([ + 'a', + 'a-sampleone', + 'a-sampletwo', + 'a-embedded', + 'a-embedded-othertopic', + 'a-embedded-extras', + ]); + // Acts as the root, so that the next level is a group we're testing. + expect(index!.a).toMatchObject({ + type: 'root', + id: 'a', + children: ['a-sampleone', 'a-sampletwo', 'a-embedded'], + tags: ['shared'], + }); + // The object of this test. + expect(index!['a-embedded']).toMatchObject({ + type: 'group', + id: 'a-embedded', + parent: 'a', + name: 'Embedded', + tags: ['shared', 'embedded-docs-specific'], + }); + }); // Stories can get out of order for a few reasons -- see reproductions on // https://github.com/storybookjs/storybook/issues/5518 it('does the right thing for out of order stories', async () => { @@ -1515,6 +1590,7 @@ describe('stories API', () => { "parent": "a", "prepared": false, "renderLabel": undefined, + "tags": [], "title": "a", "type": "story", }, @@ -1526,6 +1602,7 @@ describe('stories API', () => { "parent": "a", "prepared": false, "renderLabel": undefined, + "tags": [], "title": "a", "type": "story", }, @@ -1581,6 +1658,7 @@ describe('stories API', () => { "parent": "a", "prepared": false, "renderLabel": undefined, + "tags": [], "title": "a", "type": "story", }, @@ -1623,6 +1701,7 @@ describe('stories API', () => { "parent": "a", "prepared": false, "renderLabel": undefined, + "tags": [], "title": "a", "type": "story", }, @@ -1634,6 +1713,7 @@ describe('stories API', () => { "parent": "a", "prepared": false, "renderLabel": undefined, + "tags": [], "title": "a", "type": "story", }, diff --git a/code/core/src/manager/components/sidebar/LegacyRender.tsx b/code/core/src/manager/components/sidebar/LegacyRender.tsx index f0e74f460219..065ad061c995 100644 --- a/code/core/src/manager/components/sidebar/LegacyRender.tsx +++ b/code/core/src/manager/components/sidebar/LegacyRender.tsx @@ -7,6 +7,12 @@ import { EyeIcon, PlayHollowIcon, StopAltHollowIcon } from '@storybook/icons'; import type { TestProviders } from '@storybook/core/core-events'; import { useStorybookApi } from '@storybook/core/manager-api'; +const Container = styled.div({ + display: 'flex', + justifyContent: 'space-between', + padding: '8px 2px', +}); + const Info = styled.div({ display: 'flex', flexDirection: 'column', @@ -35,7 +41,7 @@ export const LegacyRender = ({ ...state }: TestProviders[keyof TestProviders]) = const api = useStorybookApi(); return ( - <> + <Container> <Info> <TitleWrapper crashed={state.crashed} id="testing-module-title"> <Title {...state} /> @@ -84,6 +90,6 @@ export const LegacyRender = ({ ...state }: TestProviders[keyof TestProviders]) = </> )} </Actions> - </> + </Container> ); }; diff --git a/code/core/src/manager/components/sidebar/SidebarBottom.stories.tsx b/code/core/src/manager/components/sidebar/SidebarBottom.stories.tsx index 898b02f28945..22cf6e68ef96 100644 --- a/code/core/src/manager/components/sidebar/SidebarBottom.stories.tsx +++ b/code/core/src/manager/components/sidebar/SidebarBottom.stories.tsx @@ -155,7 +155,9 @@ export const DynamicHeight: StoryObj = { play: async ({ canvasElement }) => { const screen = await within(canvasElement); - const toggleButton = await screen.getByLabelText('Collapse testing module'); + const toggleButton = await screen.getByLabelText(/Expand/); + await userEvent.click(toggleButton); + const content = await screen.findByText('CUSTOM CONTENT WITH DYNAMIC HEIGHT'); const collapse = await screen.getByTestId('collapse'); diff --git a/code/core/src/manager/components/sidebar/TestingModule.stories.tsx b/code/core/src/manager/components/sidebar/TestingModule.stories.tsx index 426d94ca60df..66fd53c488a0 100644 --- a/code/core/src/manager/components/sidebar/TestingModule.stories.tsx +++ b/code/core/src/manager/components/sidebar/TestingModule.stories.tsx @@ -1,14 +1,21 @@ import React from 'react'; +import type { Listener } from '@storybook/core/channels'; +import { styled } from '@storybook/core/theming'; import { Addon_TypesEnum } from '@storybook/core/types'; import type { Meta, StoryObj } from '@storybook/react'; -import { fn, userEvent } from '@storybook/test'; +import { fireEvent, fn } from '@storybook/test'; -import type { TestProviders } from '@storybook/core/core-events'; -import { ManagerContext } from '@storybook/core/manager-api'; +import { TESTING_MODULE_CONFIG_CHANGE, type TestProviders } from '@storybook/core/core-events'; +import { ManagerContext, mockChannel } from '@storybook/core/manager-api'; import { TestingModule } from './TestingModule'; +const TestProvider = styled.div({ + padding: 8, + fontSize: 12, +}); + const baseState = { details: {}, cancellable: false, @@ -24,13 +31,8 @@ const testProviders: TestProviders[keyof TestProviders][] = [ type: Addon_TypesEnum.experimental_TEST_PROVIDER, id: 'component-tests', name: 'Component tests', - render: () => ( - <> - Component tests - <br /> - Ran 2 seconds ago - </> - ), + title: () => 'Component tests', + description: () => 'Ran 2 seconds ago', runnable: true, watchable: true, ...baseState, @@ -39,13 +41,8 @@ const testProviders: TestProviders[keyof TestProviders][] = [ type: Addon_TypesEnum.experimental_TEST_PROVIDER, id: 'visual-tests', name: 'Visual tests', - render: () => ( - <> - Visual tests - <br /> - Not run - </> - ), + title: () => 'Visual tests', + description: () => 'Not run', runnable: true, ...baseState, }, @@ -53,20 +50,23 @@ const testProviders: TestProviders[keyof TestProviders][] = [ type: Addon_TypesEnum.experimental_TEST_PROVIDER, id: 'linting', name: 'Linting', - render: () => ( - <> - Linting - <br /> - Watching for changes - </> - ), + render: () => <TestProvider>Custom render function</TestProvider>, ...baseState, watching: true, }, ]; +let triggerUpdate: () => void; +const channel = mockChannel(); const managerContext: any = { api: { + on: (eventName: string, listener: Listener) => { + if (eventName === TESTING_MODULE_CONFIG_CHANGE) { + triggerUpdate = listener; + } + return channel.on(eventName, listener); + }, + off: (eventName: string, listener: Listener) => channel.off(eventName, listener), runTestProvider: fn().mockName('api::runTestProvider'), cancelTestProvider: fn().mockName('api::cancelTestProvider'), updateTestProviderState: fn().mockName('api::updateTestProviderState'), @@ -104,10 +104,11 @@ type Story = StoryObj<typeof meta>; export const Default: Story = {}; -export const Collapsed: Story = { +export const Expanded: Story = { play: async ({ canvas }) => { - const button = await canvas.findByRole('button', { name: /Collapse/ }); - await userEvent.click(button); + const button = await canvas.findByRole('button', { name: /Expand/ }); + await fireEvent.click(button); + await new Promise((resolve) => setTimeout(resolve, 500)); }, }; @@ -116,6 +117,7 @@ export const Statuses: Story = { errorCount: 14, warningCount: 42, }, + play: Expanded.play, }; export const ErrorsActive: Story = { @@ -123,6 +125,7 @@ export const ErrorsActive: Story = { ...Statuses.args, errorsActive: true, }, + play: Expanded.play, }; export const WarningsActive: Story = { @@ -130,6 +133,7 @@ export const WarningsActive: Story = { ...Statuses.args, warningsActive: true, }, + play: Expanded.play, }; export const BothActive: Story = { @@ -138,28 +142,29 @@ export const BothActive: Story = { errorsActive: true, warningsActive: true, }, + play: Expanded.play, }; export const CollapsedStatuses: Story = { args: Statuses.args, - play: Collapsed.play, }; export const Running: Story = { args: { testProviders: [{ ...testProviders[0], running: true }, ...testProviders.slice(1)], }, + play: Expanded.play, }; export const RunningAll: Story = { args: { testProviders: testProviders.map((tp) => ({ ...tp, running: !!tp.runnable })), }, + play: Expanded.play, }; export const CollapsedRunning: Story = { args: RunningAll.args, - play: Collapsed.play, }; export const Cancellable: Story = { @@ -169,6 +174,7 @@ export const Cancellable: Story = { ...testProviders.slice(1), ], }, + play: Expanded.play, }; export const Cancelling: Story = { @@ -178,12 +184,14 @@ export const Cancelling: Story = { ...testProviders.slice(1), ], }, + play: Expanded.play, }; export const Watching: Story = { args: { testProviders: [{ ...testProviders[0], watching: true }, ...testProviders.slice(1)], }, + play: Expanded.play, }; export const Failing: Story = { @@ -193,12 +201,14 @@ export const Failing: Story = { ...testProviders.slice(1), ], }, + play: Expanded.play, }; export const Failed: Story = { args: { testProviders: [{ ...testProviders[0], failed: true }, ...testProviders.slice(1)], }, + play: Expanded.play, }; export const Crashed: Story = { @@ -207,17 +217,26 @@ export const Crashed: Story = { { ...testProviders[0], render: () => ( - <> + <TestProvider> Component tests didn't complete <br /> Problems! - </> + </TestProvider> ), crashed: true, }, ...testProviders.slice(1), ], }, + play: Expanded.play, +}; + +export const Updated: Story = { + args: {}, + play: async (context) => { + await Expanded.play!(context); + triggerUpdate?.(); + }, }; export const NoTestProvider: Story = { diff --git a/code/core/src/manager/components/sidebar/TestingModule.tsx b/code/core/src/manager/components/sidebar/TestingModule.tsx index 5013ff54525c..b7d5536ce203 100644 --- a/code/core/src/manager/components/sidebar/TestingModule.tsx +++ b/code/core/src/manager/components/sidebar/TestingModule.tsx @@ -1,18 +1,11 @@ -import React, { - Fragment, - type SyntheticEvent, - useCallback, - useEffect, - useRef, - useState, -} from 'react'; +import React, { type SyntheticEvent, useCallback, useEffect, useRef, useState } from 'react'; import { Button, TooltipNote } from '@storybook/core/components'; import { WithTooltip } from '@storybook/core/components'; import { keyframes, styled } from '@storybook/core/theming'; import { ChevronSmallUpIcon, PlayAllHollowIcon } from '@storybook/icons'; -import type { TestProviders } from '@storybook/core/core-events'; +import { TESTING_MODULE_CONFIG_CHANGE, type TestProviders } from '@storybook/core/core-events'; import { useStorybookApi } from '@storybook/core/manager-api'; import { LegacyRender } from './LegacyRender'; @@ -29,42 +22,42 @@ const spin = keyframes({ '100%': { transform: 'rotate(360deg)' }, }); -const Outline = styled.div<{ crashed: boolean; failed: boolean; running: boolean }>( - ({ crashed, running, theme, failed }) => ({ - position: 'relative', - lineHeight: '20px', - width: '100%', - padding: 1, - overflow: 'hidden', - background: `var(--sb-sidebar-bottom-card-background, ${theme.background.content})`, - borderRadius: - `var(--sb-sidebar-bottom-card-border-radius, ${theme.appBorderRadius + 1}px)` as any, - boxShadow: `inset 0 0 0 1px ${crashed && !running ? theme.color.negative : theme.appBorderColor}, var(--sb-sidebar-bottom-card-box-shadow, 0 1px 2px 0 rgba(0, 0, 0, 0.05), 0px -5px 20px 10px ${theme.background.app})`, - transitionProperty: - 'color, background-color, border-color, text-decoration-color, fill, stroke', - transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)', - transitionDuration: '0.15s', +const Outline = styled.div<{ + crashed: boolean; + failed: boolean; + running: boolean; + updated: boolean; +}>(({ crashed, failed, running, theme, updated }) => ({ + position: 'relative', + lineHeight: '20px', + width: '100%', + padding: 1, + overflow: 'hidden', + backgroundColor: `var(--sb-sidebar-bottom-card-background, ${theme.background.content})`, + borderRadius: + `var(--sb-sidebar-bottom-card-border-radius, ${theme.appBorderRadius + 1}px)` as any, + boxShadow: `inset 0 0 0 1px ${crashed && !running ? theme.color.negative : updated ? theme.color.positive : theme.appBorderColor}, var(--sb-sidebar-bottom-card-box-shadow, 0 1px 2px 0 rgba(0, 0, 0, 0.05), 0px -5px 20px 10px ${theme.background.app})`, + transition: 'box-shadow 1s', - '&:after': { - content: '""', - display: running ? 'block' : 'none', - position: 'absolute', - left: '50%', - top: '50%', - marginLeft: 'calc(max(100vw, 100vh) * -0.5)', - marginTop: 'calc(max(100vw, 100vh) * -0.5)', - height: 'max(100vw, 100vh)', - width: 'max(100vw, 100vh)', - animation: `${spin} 3s linear infinite`, - background: failed - ? // Hardcoded colors to prevent themes from messing with them (orange+gold, secondary+seafoam) - `conic-gradient(transparent 90deg, #FC521F 150deg, #FFAE00 210deg, transparent 270deg)` - : `conic-gradient(transparent 90deg, #029CFD 150deg, #37D5D3 210deg, transparent 270deg)`, - opacity: 1, - willChange: 'auto', - }, - }) -); + '&:after': { + content: '""', + display: running ? 'block' : 'none', + position: 'absolute', + left: '50%', + top: '50%', + marginLeft: 'calc(max(100vw, 100vh) * -0.5)', + marginTop: 'calc(max(100vw, 100vh) * -0.5)', + height: 'max(100vw, 100vh)', + width: 'max(100vw, 100vh)', + animation: `${spin} 3s linear infinite`, + background: failed + ? // Hardcoded colors to prevent themes from messing with them (orange+gold, secondary+seafoam) + `conic-gradient(transparent 90deg, #FC521F 150deg, #FFAE00 210deg, transparent 270deg)` + : `conic-gradient(transparent 90deg, #029CFD 150deg, #37D5D3 210deg, transparent 270deg)`, + opacity: 1, + willChange: 'auto', + }, +})); const Card = styled.div(({ theme }) => ({ position: 'relative', @@ -85,10 +78,8 @@ const Collapsible = styled.div(({ theme }) => ({ })); const Content = styled.div({ - padding: '12px 6px', display: 'flex', flexDirection: 'column', - gap: '12px', }); const Bar = styled.div<{ onClick?: (e: SyntheticEvent) => void }>(({ onClick }) => ({ @@ -145,11 +136,13 @@ const StatusButton = styled(Button)<{ status: 'negative' | 'warning' }>( }) ); -const TestProvider = styled.div({ - display: 'flex', - justifyContent: 'space-between', - gap: 6, -}); +const TestProvider = styled.div(({ theme }) => ({ + padding: 4, + + '&:not(:last-child)': { + boxShadow: `inset 0 -1px 0 ${theme.appBorderColor}`, + }, +})); interface TestingModuleProps { testProviders: TestProviders[keyof TestProviders][]; @@ -172,13 +165,13 @@ export const TestingModule = ({ }: TestingModuleProps) => { const api = useStorybookApi(); - const contentRef = useRef<HTMLDivElement>(null); const timeoutRef = useRef<null | ReturnType<typeof setTimeout>>(null); - + const contentRef = useRef<HTMLDivElement>(null); const [maxHeight, setMaxHeight] = useState(DEFAULT_HEIGHT); - - const [isCollapsed, setCollapsed] = useState(false); + const [isUpdated, setUpdated] = useState(false); + const [isCollapsed, setCollapsed] = useState(true); const [isChangingCollapse, setChangingCollapse] = useState(false); + useEffect(() => { if (contentRef.current) { setMaxHeight(contentRef.current?.getBoundingClientRect().height || DEFAULT_HEIGHT); @@ -197,6 +190,19 @@ export const TestingModule = ({ } }, [isCollapsed]); + useEffect(() => { + let timeout: ReturnType<typeof setTimeout>; + const handler = () => { + setUpdated(true); + timeout = setTimeout(setUpdated, 1000, false); + }; + api.on(TESTING_MODULE_CONFIG_CHANGE, handler); + return () => { + api.off(TESTING_MODULE_CONFIG_CHANGE, handler); + clearTimeout(timeout); + }; + }, [api]); + const toggleCollapsed = useCallback((event: SyntheticEvent) => { event.stopPropagation(); setChangingCollapse(true); @@ -224,6 +230,7 @@ export const TestingModule = ({ running={isRunning} crashed={isCrashed} failed={isFailed || errorCount > 0} + updated={isUpdated} > <Card> {hasTestProviders && ( @@ -238,13 +245,9 @@ export const TestingModule = ({ <Content ref={contentRef}> {testProviders.map((state) => { const { render: Render } = state; - return Render ? ( - <Fragment key={state.id}> - <Render {...state} /> - </Fragment> - ) : ( - <TestProvider key={state.id}> - <LegacyRender {...state} /> + return ( + <TestProvider key={state.id} data-module-id={state.id}> + {Render ? <Render {...state} /> : <LegacyRender {...state} />} </TestProvider> ); })} diff --git a/code/core/src/manager/components/sidebar/__tests__/Sidebar.test.tsx b/code/core/src/manager/components/sidebar/__tests__/Sidebar.test.tsx index 2e1583fdcf87..0840be5fcd71 100644 --- a/code/core/src/manager/components/sidebar/__tests__/Sidebar.test.tsx +++ b/code/core/src/manager/components/sidebar/__tests__/Sidebar.test.tsx @@ -50,6 +50,7 @@ const generateStories = ({ title, refId }: { title: string; refId?: string }): A name: root, children: [componentId], startCollapsed: false, + tags: [], }, { type: 'component', diff --git a/code/core/src/types/modules/api-stories.ts b/code/core/src/types/modules/api-stories.ts index c53d379b7192..5f7b8dfe864b 100644 --- a/code/core/src/types/modules/api-stories.ts +++ b/code/core/src/types/modules/api-stories.ts @@ -7,6 +7,7 @@ export interface API_BaseEntry { id: StoryId; depth: number; name: string; + tags: Tag[]; refId?: string; renderLabel?: (item: API_BaseEntry, api: any) => any; } @@ -27,7 +28,6 @@ export interface API_ComponentEntry extends API_BaseEntry { type: 'component'; parent?: StoryId; children: StoryId[]; - tags: Tag[]; } export interface API_DocsEntry extends API_BaseEntry { @@ -35,7 +35,6 @@ export interface API_DocsEntry extends API_BaseEntry { parent: StoryId; title: ComponentTitle; importPath: Path; - tags: Tag[]; prepared: boolean; parameters?: { [parameterName: string]: any; @@ -47,7 +46,6 @@ export interface API_StoryEntry extends API_BaseEntry { parent: StoryId; title: ComponentTitle; importPath: Path; - tags: Tag[]; prepared: boolean; parameters?: { [parameterName: string]: any; 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 2b60a9314d51..68276fdb1a22 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 @@ -49,11 +49,14 @@ test.describe("component testing", () => { await sbPage.navigateToStory("addons/test", "Mismatch Failure"); + const expandButton = await page.getByLabel('Expand testing module') + await expandButton.click(); + // For whatever reason, sometimes it takes longer for the story to load const storyElement = sbPage .getCanvasBodyElement() .getByRole("button", { name: "test" }); - await expect(storyElement).toBeVisible({ timeout: 10000 }); + await expect(storyElement).toBeVisible({ timeout: 30000 }); await sbPage.viewAddonPanel("Component tests"); @@ -65,13 +68,12 @@ test.describe("component testing", () => { if ((await testStoryElement.getAttribute("aria-expanded")) !== "true") { testStoryElement.click(); } - + const testingModuleDescription = await page.locator('#testing-module-description'); await expect(testingModuleDescription).toContainText('Not run'); const runTestsButton = await page.getByLabel('Start component tests') - await runTestsButton.click(); await expect(testingModuleDescription).toContainText('Testing', { timeout: 60000 }); @@ -117,14 +119,17 @@ test.describe("component testing", () => { const sbPage = new SbPage(page, expect); await sbPage.navigateToStory("addons/test", "Expected Failure"); - + + const expandButton = await page.getByLabel('Expand testing module') + await expandButton.click(); + // For whatever reason, sometimes it takes longer for the story to load const storyElement = sbPage .getCanvasBodyElement() .getByRole("button", { name: "test" }); - await expect(storyElement).toBeVisible({ timeout: 10000 }); + await expect(storyElement).toBeVisible({ timeout: 30000 }); - await expect(page.locator('#testing-module-title')).toHaveText('Component tests'); + await expect(page.locator('#testing-module-title')).toHaveText('Run local tests'); const testingModuleDescription = await page.locator('#testing-module-description'); @@ -142,7 +147,7 @@ test.describe("component testing", () => { // Wait for test results to appear await expect(testingModuleDescription).toHaveText(/Ran \d+ tests/, { timeout: 30000 }); - + await expect(runTestsButton).toBeEnabled(); await expect(watchModeButton).toBeEnabled(); @@ -186,11 +191,14 @@ test.describe("component testing", () => { const sbPage = new SbPage(page, expect); await sbPage.navigateToStory("addons/test", "Expected Failure"); + const expandButton = await page.getByLabel('Expand testing module') + await expandButton.click(); + // For whatever reason, sometimes it takes longer for the story to load const storyElement = sbPage .getCanvasBodyElement() .getByRole("button", { name: "test" }); - await expect(storyElement).toBeVisible({ timeout: 10000 }); + await expect(storyElement).toBeVisible({ timeout: 30000 }); await page.getByLabel("Enable watch mode for Component tests").click();