diff --git a/code/core/src/components/components/tooltip/ListItem.tsx b/code/core/src/components/components/tooltip/ListItem.tsx index 1752abeb1f8d..e150e7c8ddbf 100644 --- a/code/core/src/components/components/tooltip/ListItem.tsx +++ b/code/core/src/components/components/tooltip/ListItem.tsx @@ -51,6 +51,7 @@ export interface RightProps { const Right = styled.span({ display: 'flex', + marginLeft: 10, '& svg': { height: 12, width: 12, @@ -135,10 +136,7 @@ const Item = styled.div( padding: '7px 10px', display: 'flex', alignItems: 'center', - - '& > * + *': { - paddingLeft: 10, - }, + gap: 10, }), ({ theme, href, onClick }) => (href || onClick) && { diff --git a/code/core/src/manager-api/modules/stories.ts b/code/core/src/manager-api/modules/stories.ts index e92e7a90ce55..879207be750f 100644 --- a/code/core/src/manager-api/modules/stories.ts +++ b/code/core/src/manager-api/modules/stories.ts @@ -1,4 +1,6 @@ import type { + API_ActionsState, + API_ActionsUpdate, API_ComposedRef, API_DocsEntry, API_FilterFunction, @@ -73,6 +75,7 @@ export interface SubState extends API_LoadedRefData { storyId: StoryId; internal_index?: API_PreparedStoryIndex; viewMode: API_ViewMode; + actions: API_ActionsState; status: API_StatusState; filters: Record; } @@ -268,6 +271,17 @@ export interface SubAPI { * @returns {Promise} A promise that resolves when the preview has been set as initialized. */ setPreviewInitialized: (ref?: ComposedRef) => Promise; + /** + * Updates the status of a collection of stories. + * + * @param {string} addonId - The ID of the addon to update. + * @param {StatusUpdate} update - An object containing the updated status information. + * @returns {Promise} A promise that resolves when the status has been updated. + */ + experimental_updateActions: ( + addonId: string, + update: API_ActionsUpdate | ((state: API_ActionsState) => API_ActionsUpdate) + ) => Promise; /** * Updates the status of a collection of stories. * @@ -631,6 +645,44 @@ export const init: ModuleFn = ({ }, /* EXPERIMENTAL APIs */ + experimental_updateActions: async (id, input) => { + const { actions, internal_index: index } = store.getState(); + const newActions = { ...actions }; + + const update = typeof input === 'function' ? input(actions) : input; + + if (!id || Object.keys(update).length === 0) { + return; + } + + Object.entries(update).forEach(([storyId, value]) => { + if (!storyId || typeof value !== 'object') { + return; + } + newActions[storyId] = { ...(newActions[storyId] || {}) }; + if (value === null) { + delete newActions[storyId][id]; + } else { + newActions[storyId][id] = value; + } + + if (Object.keys(newActions[storyId]).length === 0) { + delete newActions[storyId]; + } + }); + + await store.setState({ actions: newActions }, { persistence: 'session' }); + + if (index) { + // We need to re-prepare the index + await api.setIndex(index); + + const refs = await fullAPI.getRefs(); + Object.entries(refs).forEach(([refId, { internal_index, ...ref }]) => { + fullAPI.setRef(refId, { ...ref, storyIndex: internal_index }, true); + }); + } + }, experimental_updateStatus: async (id, input) => { const { status, internal_index: index } = store.getState(); const newStatus = { ...status }; @@ -903,6 +955,7 @@ export const init: ModuleFn = ({ viewMode: initialViewMode, hasCalledSetOptions: false, previewInitialized: false, + actions: {}, status: {}, filters: config?.sidebar?.filters || {}, }, diff --git a/code/core/src/manager/components/sidebar/Refs.stories.tsx b/code/core/src/manager/components/sidebar/Refs.stories.tsx index a26cad976dcb..23c1f9b6b185 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,17 @@ import { Ref } from './Refs'; import { mockDataset } from './mockdata'; import type { RefType } from './types'; +const managerContext: any = { + api: { + emit: fn().mockName('api::emit'), + on: fn().mockName('api::on'), + off: fn().mockName('api::off'), + }, + state: { + docsOptions: {}, + }, +}; + export default { component: Ref, title: 'Sidebar/Refs', @@ -16,7 +29,7 @@ export default { globals: { sb_theme: 'side-by-side' }, decorators: [ (storyFn: any) => ( - + {storyFn()} diff --git a/code/core/src/manager/components/sidebar/Sidebar.stories.tsx b/code/core/src/manager/components/sidebar/Sidebar.stories.tsx index dcc6b06096c2..4d77a2da7d63 100644 --- a/code/core/src/manager/components/sidebar/Sidebar.stories.tsx +++ b/code/core/src/manager/components/sidebar/Sidebar.stories.tsx @@ -83,6 +83,7 @@ const meta = { storyId, refId: DEFAULT_REF_ID, refs: {}, + actions: {}, status: {}, showCreateStoryButton: true, isDevelopment: true, @@ -236,8 +237,11 @@ export const StatusesCollapsed: Story = { return { ...acc, [id]: { - addonA: { status: 'warn', title: 'Addon A', description: 'We just wanted you to know' }, - addonB: { status: 'error', title: 'Addon B', description: 'This is a big deal!' }, + a: { status: 'warn', title: 'Visual changes', description: 'Look at that' }, + b: { status: 'error', title: 'Component tests', description: 'This is a big deal!' }, + c: { status: 'error', title: 'Accessibility violations', description: '', count: 2 }, + d: { status: 'warn', title: 'Accessibility warnings', description: '', count: 1 }, + e: { status: 'warn', title: 'Coverage', description: '', data: { score: '50%' } }, }, }; } @@ -258,8 +262,11 @@ export const StatusesOpen: Story = { return { ...acc, [id]: { - addonA: { status: 'warn', title: 'Addon A', description: 'We just wanted you to know' }, - addonB: { status: 'error', title: 'Addon B', description: 'This is a big deal!' }, + a: { status: 'warn', title: 'Visual changes', description: 'Look at that' }, + b: { status: 'error', title: 'Component tests', description: 'This is a big deal!' }, + c: { status: 'error', title: 'Accessibility violations', description: '', count: 2 }, + d: { status: 'warn', title: 'Accessibility warnings', description: '', count: 1 }, + e: { status: 'warn', title: 'Coverage', description: '', data: { score: '50%' } }, }, }; }, {}), diff --git a/code/core/src/manager/components/sidebar/Sidebar.tsx b/code/core/src/manager/components/sidebar/Sidebar.tsx index bb3be73f425d..b6e5fa6ff5a3 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'; @@ -83,19 +82,28 @@ const Swap = React.memo(function Swap({ ); }); -const useCombination = ( - index: SidebarProps['index'], - indexError: SidebarProps['indexError'], - previewInitialized: SidebarProps['previewInitialized'], - status: SidebarProps['status'], - refs: SidebarProps['refs'] -): CombinedDataset => { +const useCombination = ({ + index, + indexError, + previewInitialized, + actions, + status, + refs, +}: { + index: SidebarProps['index']; + indexError: SidebarProps['indexError']; + previewInitialized: SidebarProps['previewInitialized']; + actions: SidebarProps['actions']; + status: SidebarProps['status']; + refs: SidebarProps['refs']; +}): CombinedDataset => { const hash = useMemo( () => ({ [DEFAULT_REF_ID]: { index, indexError, previewInitialized, + actions, status, title: null, id: DEFAULT_REF_ID, @@ -103,7 +111,7 @@ const useCombination = ( }, ...refs, }), - [refs, index, indexError, previewInitialized, status] + [refs, index, indexError, previewInitialized, actions, status] ); // @ts-expect-error (non strict) return useMemo(() => ({ hash, entries: Object.entries(hash) }), [hash]); @@ -113,6 +121,7 @@ const isRendererReact = global.STORYBOOK_RENDERER === 'react'; export interface SidebarProps extends API_LoadedRefData { refs: State['refs']; + actions: State['actions']; status: State['status']; menu: any[]; extra: Addon_SidebarTopType[]; @@ -132,6 +141,7 @@ export const Sidebar = React.memo(function Sidebar({ index, indexJson, indexError, + actions, status, previewInitialized, menu, @@ -146,7 +156,7 @@ export const Sidebar = React.memo(function Sidebar({ const [isFileSearchModalOpen, setIsFileSearchModalOpen] = useState(false); // @ts-expect-error (non strict) const selected: Selection = useMemo(() => storyId && { storyId, refId }, [storyId, refId]); - const dataset = useCombination(index, indexError, previewInitialized, status, refs); + const dataset = useCombination({ index, indexError, previewInitialized, actions, status, refs }); const isLoading = !index && !indexError; const lastViewedProps = useLastViewed(selected); const { isMobile } = useLayout(); diff --git a/code/core/src/manager/components/sidebar/StatusContext.tsx b/code/core/src/manager/components/sidebar/StatusContext.tsx index d143a3db9be5..841b5d9a4968 100644 --- a/code/core/src/manager/components/sidebar/StatusContext.tsx +++ b/code/core/src/manager/components/sidebar/StatusContext.tsx @@ -30,7 +30,7 @@ export const useStatusSummary = (item: Item) => { ) { for (const storyId of getDescendantIds(data, item.id, false)) { for (const value of Object.values(status[storyId] || {})) { - summary.counts[value.status]++; + summary.counts[value.status] += value.count ?? 1; summary.statuses[value.status][storyId] = summary.statuses[value.status][storyId] || []; summary.statuses[value.status][storyId].push(value); } diff --git a/code/core/src/manager/components/sidebar/StoryMenu.stories.tsx b/code/core/src/manager/components/sidebar/StoryMenu.stories.tsx new file mode 100644 index 000000000000..17a4313310e5 --- /dev/null +++ b/code/core/src/manager/components/sidebar/StoryMenu.stories.tsx @@ -0,0 +1,69 @@ +import React from 'react'; + +import type { Meta, StoryObj } from '@storybook/react'; +import { fn, userEvent } from '@storybook/test'; + +import { ManagerContext } from '@storybook/core/manager-api'; + +import { StoryMenu } from './StoryMenu'; + +const managerContext: any = { + api: { + emit: fn().mockName('api::emit'), + on: fn().mockName('api::on'), + off: fn().mockName('api::off'), + }, +}; + +const meta = { + component: StoryMenu, + args: { + storyId: 'story-id', + isSelected: false, + onSelectStoryId: () => {}, + actions: { + addonA: { + title: 'Run component tests', + description: 'Run component tests for this story', + event: 'COMPONENT_TESTS_RUN', + }, + addonB: { + title: 'Run accessibility tests', + event: 'A11Y_TESTS_RUN', + }, + }, + status: { + addonA: { + title: 'Accessibility violations', + status: 'error', + count: 3, + }, + addonB: { + title: 'Visual changes', + status: 'warn', + }, + }, + statusIcon: null, + statusValue: 'unknown', + }, + parameters: { + layout: 'fullscreen', + }, + decorators: [ + (Story) => {Story()}, + (Story) => ( +
+ {Story()} +
+ ), + ], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + play: async ({ canvas }) => + userEvent.click(await canvas.findByRole('button', { name: 'Story menu' })), +}; diff --git a/code/core/src/manager/components/sidebar/StoryMenu.tsx b/code/core/src/manager/components/sidebar/StoryMenu.tsx new file mode 100644 index 000000000000..dce49680010a --- /dev/null +++ b/code/core/src/manager/components/sidebar/StoryMenu.tsx @@ -0,0 +1,130 @@ +import React, { type ReactElement, useState } from 'react'; + +import { TooltipLinkList, WithTooltip } from '@storybook/core/components'; +import { styled, useTheme } from '@storybook/core/theming'; +import { + EllipsisIcon, + PlayHollowIcon, + StatusFailIcon, + StatusPassIcon, + StatusWarnIcon, + SyncIcon, +} from '@storybook/icons'; +import type { API_StatusValue } from '@storybook/types'; + +import { type State, useChannel } from '@storybook/core/manager-api'; + +import type { Link } from '../../../components/components/tooltip/TooltipLinkList'; +import { StatusButton } from './StatusButton'; + +const STATUS_ORDER: API_StatusValue[] = ['success', 'error', 'warn', 'pending', 'unknown']; + +const MenuButton = styled(StatusButton)<{ forceVisible: boolean }>(({ forceVisible }) => ({ + visibility: forceVisible ? 'visible' : ('var(--story-menu-visibility, hidden)' as any), + '&:active, &:focus': { + visibility: 'visible', + }, +})); + +const hideOnClick = (links: Link[][], onHide: () => void) => + links.map((group) => + group.map((link) => ({ + ...link, + onClick: (...args: Parameters>) => { + link.onClick?.(...args); + onHide(); + }, + })) + ); + +interface StoryMenuProps { + storyId: string; + isSelected: boolean; + onSelectStoryId: (itemId: string) => void; + actions: State['actions'][keyof State['actions']]; + status: State['status'][keyof State['status']]; + statusIcon: ReactElement | null; + statusValue: API_StatusValue; +} + +export const StoryMenu = ({ + storyId, + isSelected, + onSelectStoryId, + actions, + status, + statusIcon, + statusValue, +}: StoryMenuProps) => { + const [visible, setVisible] = useState(false); + const theme = useTheme(); + const emit = useChannel({}); + + const links: Link[][] = [ + // Story options (built-ins): + [ + // { + // id: 'rename', + // title: 'Rename story', + // onClick: () => console.log('Rename'), + // icon: , + // }, + ], + + // Story statuses: + Object.entries(status || {}) + .sort((a, b) => STATUS_ORDER.indexOf(a[1].status) - STATUS_ORDER.indexOf(b[1].status)) + .map(([addonId, value]) => ({ + id: addonId, + title: value.title, + center: value.description, + right: value.data?.score ?? value.count, + 'aria-label': `Test status for ${value.title}: ${value.status}`, + icon: { + success: , + error: , + warn: , + pending: , + unknown: null, + }[value.status], + onClick: () => { + onSelectStoryId(storyId); + value.onClick?.(); + }, + })), + + // Story actions: + Object.entries(actions || {}).map(([addonId, value]) => ({ + id: addonId, + title: value.title, + center: value.description, + icon: , + onClick: () => emit(value.event, [storyId]), + })), + ]; + + if (!links.some((group) => group.some(Boolean))) { + return null; + } + + return ( + event.stopPropagation()} + onVisibleChange={setVisible} + placement="bottom" + tooltip={({ onHide }) => } + > + + {statusIcon || } + + + ); +}; diff --git a/code/core/src/manager/components/sidebar/Tree.stories.tsx b/code/core/src/manager/components/sidebar/Tree.stories.tsx index a8f3a227b2dc..6122135e0b8b 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, 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,14 @@ import { DEFAULT_REF_ID } from './Sidebar'; import { Tree } from './Tree'; import { index } from './mockdata.large'; +const managerContext: any = { + api: { + emit: fn().mockName('api::emit'), + on: fn().mockName('api::on'), + off: fn().mockName('api::off'), + }, +}; + const meta = { component: Tree, title: 'Sidebar/Tree', @@ -35,6 +43,9 @@ const meta = { }, chromatic: { viewports: [380] }, }, + decorators: [ + (Story) => {Story()}, + ], } as Meta; export default meta; diff --git a/code/core/src/manager/components/sidebar/Tree.tsx b/code/core/src/manager/components/sidebar/Tree.tsx index da9267f6caa4..ae7d0fb91074 100644 --- a/code/core/src/manager/components/sidebar/Tree.tsx +++ b/code/core/src/manager/components/sidebar/Tree.tsx @@ -38,6 +38,7 @@ import { useLayout } from '../layout/LayoutProvider'; import { IconSymbols, UseSymbol } from './IconSymbols'; import { StatusButton } from './StatusButton'; import { StatusContext, useStatusSummary } from './StatusContext'; +import { StoryMenu } from './StoryMenu'; import { ComponentNode, DocumentNode, GroupNode, RootNode, StoryNode } from './TreeNode'; import { CollapseIcon } from './components/CollapseIcon'; import type { Highlight, Item } from './types'; @@ -78,10 +79,12 @@ export const LeafNodeStyleWrapper = styled.div(({ theme }) => ({ background: 'transparent', minHeight: 28, borderRadius: 4, + '--story-menu-visibility': 'hidden', '&:hover, &:focus': { background: transparentize(0.93, theme.color.secondary), outline: 'none', + '--story-menu-visibility': 'visible', }, '&[data-selected="true"]': { @@ -133,6 +136,7 @@ interface NodeProps { setExpanded: (action: ExpandAction) => void; setFullyExpanded?: () => void; onSelectStoryId: (itemId: string) => void; + actions: State['actions'][keyof State['actions']]; status: State['status'][keyof State['status']]; groupStatus: Record; api: API; @@ -141,6 +145,7 @@ interface NodeProps { const Node = React.memo(function Node({ item, + actions, status, groupStatus, refId, @@ -169,9 +174,7 @@ const Node = React.memo(function Node({ 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']; + const [statusIcon, textColor] = statusMapping[statusValue]; return ( (function Node({ Skip to canvas )} - {icon ? ( - event.stopPropagation()} - placement="bottom" - tooltip={({ onHide }) => ( - 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: , - error: , - warn: , - pending: , - unknown: null, - }[value.status], - onClick: () => { - onSelectStoryId(item.id); - value.onClick?.(); - onHide(); - }, - }))} - /> - )} - > - - {icon} - - - ) : null} + ); } @@ -310,7 +280,7 @@ const Node = React.memo(function Node({ links.push({ id: 'errors', icon: , - title: `${counts.error} ${counts.error === 1 ? 'story' : 'stories'} with errors`, + title: `${counts.error} ${counts.error === 1 ? 'error' : 'errors'}`, onClick: () => { const [firstStoryId, [firstError]] = Object.entries(statuses.error)[0]; onSelectStoryId(firstStoryId); @@ -323,7 +293,7 @@ const Node = React.memo(function Node({ links.push({ id: 'warnings', icon: , - title: `${counts.warn} ${counts.warn === 1 ? 'story' : 'stories'} with warnings`, + title: `${counts.warn} ${counts.warn === 1 ? 'warning' : 'warnings'}`, onClick: () => { const [firstStoryId, [firstWarning]] = Object.entries(statuses.warn)[0]; onSelectStoryId(firstStoryId); 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..ab8278b18e94 100644 --- a/code/core/src/manager/components/sidebar/__tests__/Sidebar.test.tsx +++ b/code/core/src/manager/components/sidebar/__tests__/Sidebar.test.tsx @@ -26,6 +26,7 @@ const factory = (props: Partial): RenderResult => { index={{}} previewInitialized refs={{}} + actions={{}} status={{}} extra={[]} {...props} diff --git a/code/core/src/manager/container/Sidebar.tsx b/code/core/src/manager/container/Sidebar.tsx index bc05d1713b59..70e6acfb0ccf 100755 --- a/code/core/src/manager/container/Sidebar.tsx +++ b/code/core/src/manager/container/Sidebar.tsx @@ -28,6 +28,7 @@ const Sidebar = React.memo(function Sideber({ onMenuClick }: SidebarProps) { // eslint-disable-next-line @typescript-eslint/naming-convention internal_index, index, + actions, status, indexError, previewInitialized, @@ -57,6 +58,7 @@ const Sidebar = React.memo(function Sideber({ onMenuClick }: SidebarProps) { indexJson: internal_index, index, indexError, + actions, status, previewInitialized, refs, diff --git a/code/core/src/types/modules/api-stories.ts b/code/core/src/types/modules/api-stories.ts index c53d379b7192..719f794f3220 100644 --- a/code/core/src/types/modules/api-stories.ts +++ b/code/core/src/types/modules/api-stories.ts @@ -118,16 +118,23 @@ export interface API_Versions { current?: API_Version; } -export type API_StatusValue = 'pending' | 'success' | 'error' | 'warn' | 'unknown'; +export interface API_ActionsObject { + event: string; + title: string; + description?: string; +} +export type API_ActionsState = Record>; +export type API_ActionsUpdate = Record; export interface API_StatusObject { status: API_StatusValue; title: string; - description: string; + description?: string; + count?: number; data?: any; onClick?: () => void; } - +export type API_StatusValue = 'pending' | 'success' | 'error' | 'warn' | 'unknown'; export type API_StatusState = Record>; export type API_StatusUpdate = Record;