Skip to content

Commit

Permalink
Merge pull request #29557 from storybookjs/norbert/addon-api-context-…
Browse files Browse the repository at this point in the history
…menu

UI: Sidebar context menu addon API
  • Loading branch information
ndelangen authored Nov 19, 2024
2 parents d6fadae + f771bdf commit 14c0ab5
Show file tree
Hide file tree
Showing 44 changed files with 1,191 additions and 458 deletions.
10 changes: 5 additions & 5 deletions code/.storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,8 @@ const ThemedSetRoot = () => {
};

// eslint-disable-next-line no-underscore-dangle
const preview = (window as any).__STORYBOOK_PREVIEW__ as PreviewWeb<ReactRenderer>;
const channel = (window as any).__STORYBOOK_ADDONS_CHANNEL__ as Channel;
const preview = (window as any).__STORYBOOK_PREVIEW__ as PreviewWeb<ReactRenderer> | 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
Expand All @@ -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(
Expand Down
101 changes: 101 additions & 0 deletions code/addons/test/src/components/ContextMenuItem.tsx
Original file line number Diff line number Diff line change
@@ -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 = (
<RelativeTime
timestamp={new Date(state.progress.finishedAt)}
testCount={state.progress.numTotalTests}
/>
);
} else if (state.watching) {
description = 'Watching for file changes';
}

return (
<div
onClick={(event) => {
// stopPropagation to prevent the parent from closing the context menu, which is the default behavior onClick
event.stopPropagation();
}}
>
<ListItem
title={title}
center={description}
right={
<Button
onClick={onClick}
variant="ghost"
padding="small"
disabled={state.crashed || isDisabled}
>
<Icon fill={theme.barTextColor} />
</Button>
}
/>
</div>
);
};
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand Down
23 changes: 23 additions & 0 deletions code/addons/test/src/components/PanelTitle.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<Spaced col={1}>
<span style={{ display: 'inline-block', verticalAlign: 'middle' }}>Component tests</span>
{interactionsCount && !hasException ? (
<Badge status="neutral">{interactionsCount}</Badge>
) : null}
{hasException ? <Badge status="negative">{interactionsCount}</Badge> : null}
</Spaced>
</div>
);
}
24 changes: 24 additions & 0 deletions code/addons/test/src/components/RelativeTime.tsx
Original file line number Diff line number Diff line change
@@ -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}`
);
};
Loading

0 comments on commit 14c0ab5

Please sign in to comment.