diff --git a/MIGRATION.md b/MIGRATION.md index 3dd1311dd620..6a9f7d85ddc5 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,5 +1,7 @@

Migration

+- [From version 8.2.x to 8.3.x](#from-version-82x-to-83x) + - [Removed `experimental_SIDEBAR_BOTTOM` and deprecated `experimental_SIDEBAR_TOP` addon types](#removed-experimental_sidebar_bottom-and-deprecated-experimental_sidebar_top-addon-types) - [From version 8.1.x to 8.2.x](#from-version-81x-to-82x) - [Failed to resolve import "@storybook/X" error](#failed-to-resolve-import-storybookx-error) - [Preview.js globals renamed to initialGlobals](#previewjs-globals-renamed-to-initialglobals) @@ -414,6 +416,14 @@ - [Packages renaming](#packages-renaming) - [Deprecated embedded addons](#deprecated-embedded-addons) +## From version 8.2.x to 8.3.x + +### Removed `experimental_SIDEBAR_BOTTOM` and deprecated `experimental_SIDEBAR_TOP` addon types + +The experimental SIDEBAR_BOTTOM addon type was removed in favor of a built-in filter UI. The enum type definition will remain available until Storybook 9.0 but will be ignored. Similarly the experimental SIDEBAR_TOP addon type is deprecated and will be removed in a future version. + +These APIs allowed addons to render arbitrary content in the Storybook sidebar. Due to potential conflicts between addons and challenges regarding styling, these APIs are/will be removed. In the future, Storybook will provide declarative API hooks to allow addons to add content to the sidebar without risk of conflicts or UI inconsistencies. One such API is `experimental_updateStatus` which allow addons to set a status for stories. The SIDEBAR_BOTTOM slot is now used to allow filtering stories with a given status. + ## From version 8.1.x to 8.2.x ### Failed to resolve import "@storybook/X" error @@ -2324,8 +2334,8 @@ export default config; #### Vite builder uses Vite config automatically -When using a [Vite-based framework](#framework-field-mandatory), Storybook will automatically use your `vite.config.(ctm)js` config file starting in 7.0. -Some settings will be overridden by Storybook so that it can function properly, and the merged settings can be modified using `viteFinal` in `.storybook/main.js` (see the [Storybook Vite configuration docs](https://storybook.js.org/docs/react/builders/vite#configuration)). +When using a [Vite-based framework](#framework-field-mandatory), Storybook will automatically use your `vite.config.(ctm)js` config file starting in 7.0. +Some settings will be overridden by Storybook so that it can function properly, and the merged settings can be modified using `viteFinal` in `.storybook/main.js` (see the [Storybook Vite configuration docs](https://storybook.js.org/docs/react/builders/vite#configuration)). If you were using `viteFinal` in 6.5 to simply merge in your project's standard Vite config, you can now remove it. For Svelte projects this means that the `svelteOptions` property in the `main.js` config should be omitted, as it will be loaded automatically via the project's `vite.config.js`. diff --git a/code/core/src/core-events/index.ts b/code/core/src/core-events/index.ts index cbb1bef3c6af..90eed7b0d5b6 100644 --- a/code/core/src/core-events/index.ts +++ b/code/core/src/core-events/index.ts @@ -47,6 +47,8 @@ enum events { STORY_ARGS_UPDATED = 'storyArgsUpdated', // Reset either a single arg of a story all args of a story RESET_STORY_ARGS = 'resetStoryArgs', + // Emitted after a filter is set + SET_FILTER = 'setFilter', // Emitted by the preview at startup once it knows the initial set of globals+globalTypes SET_GLOBALS = 'setGlobals', // Tell the preview to update the value of a global @@ -114,6 +116,7 @@ export const { SELECT_STORY, SET_CONFIG, SET_CURRENT_STORY, + SET_FILTER, SET_GLOBALS, SET_INDEX, SET_STORIES, diff --git a/code/core/src/manager-api/modules/stories.ts b/code/core/src/manager-api/modules/stories.ts index 23edae60ad95..80511847149b 100644 --- a/code/core/src/manager-api/modules/stories.ts +++ b/code/core/src/manager-api/modules/stories.ts @@ -41,6 +41,7 @@ import { DOCS_PREPARED, SET_CURRENT_STORY, SET_CONFIG, + SET_FILTER, } from '@storybook/core/core-events'; import { logger } from '@storybook/core/client-logger'; @@ -662,6 +663,8 @@ export const init: ModuleFn = ({ Object.entries(refs).forEach(([refId, { internal_index, ...ref }]) => { fullAPI.setRef(refId, { ...ref, storyIndex: internal_index }, true); }); + + provider.channel?.emit(SET_FILTER, { id }); }, }; diff --git a/code/core/src/manager/components/sidebar/FilterToggle.stories.ts b/code/core/src/manager/components/sidebar/FilterToggle.stories.ts new file mode 100644 index 000000000000..8177cbbe9713 --- /dev/null +++ b/code/core/src/manager/components/sidebar/FilterToggle.stories.ts @@ -0,0 +1,40 @@ +import { fn } from '@storybook/test'; +import { FilterToggle } from './FilterToggle'; + +export default { + component: FilterToggle, + args: { + active: false, + onClick: fn(), + }, +}; + +export const Errors = { + args: { + count: 2, + label: 'Error', + status: 'critical', + }, +}; + +export const ErrorsActive = { + args: { + ...Errors.args, + active: true, + }, +}; + +export const Warning = { + args: { + count: 12, + label: 'Warning', + status: 'warning', + }, +}; + +export const WarningActive = { + args: { + ...Warning.args, + active: true, + }, +}; diff --git a/code/core/src/manager/components/sidebar/FilterToggle.tsx b/code/core/src/manager/components/sidebar/FilterToggle.tsx new file mode 100644 index 000000000000..93d056fa59ac --- /dev/null +++ b/code/core/src/manager/components/sidebar/FilterToggle.tsx @@ -0,0 +1,59 @@ +import { Badge as BaseBadge, IconButton } from '@storybook/components'; +import { css, styled } from '@storybook/theming'; +import React, { type ComponentProps } from 'react'; + +const Badge = styled(BaseBadge)(({ theme }) => ({ + padding: '4px 8px', + fontSize: theme.typography.size.s1, +})); + +const Button = styled(IconButton)( + ({ theme }) => ({ + fontSize: theme.typography.size.s2, + '&:hover [data-badge][data-status=warning], [data-badge=true][data-status=warning]': { + background: '#E3F3FF', + borderColor: 'rgba(2, 113, 182, 0.1)', + color: '#0271B6', + }, + '&:hover [data-badge][data-status=critical], [data-badge=true][data-status=critical]': { + background: theme.background.negative, + boxShadow: `inset 0 0 0 1px rgba(182, 2, 2, 0.1)`, + color: theme.color.negativeText, + }, + }), + ({ active, theme }) => + !active && + css({ + '&:hover': { + color: theme.base === 'light' ? theme.color.defaultText : theme.color.light, + }, + }) +); + +const Label = styled.span(({ theme }) => ({ + color: theme.base === 'light' ? theme.color.defaultText : theme.color.light, +})); + +interface FilterToggleProps { + active: boolean; + count: number; + label: string; + status: ComponentProps['status']; +} + +export const FilterToggle = ({ + active, + count, + label, + status, + ...props +}: FilterToggleProps & Omit, 'status'>) => { + return ( + + ); +}; diff --git a/code/core/src/manager/components/sidebar/Sidebar.stories.tsx b/code/core/src/manager/components/sidebar/Sidebar.stories.tsx index b954cefc84c2..cb13169d9365 100644 --- a/code/core/src/manager/components/sidebar/Sidebar.stories.tsx +++ b/code/core/src/manager/components/sidebar/Sidebar.stories.tsx @@ -4,7 +4,7 @@ import type { IndexHash, State } from '@storybook/core/manager-api'; import { ManagerContext, types } from '@storybook/core/manager-api'; import type { StoryObj, Meta } from '@storybook/react'; import { within, userEvent, expect, fn } from '@storybook/test'; -import type { Addon_SidebarTopType } from '@storybook/core/types'; +import type { Addon_SidebarTopType, API_StatusState } from '@storybook/core/types'; import { Button, IconButton } from '@storybook/core/components'; import { FaceHappyIcon } from '@storybook/icons'; import { Sidebar, DEFAULT_REF_ID } from './Sidebar'; @@ -26,6 +26,26 @@ const storyId = 'root-1-child-a2--grandchild-a1-1'; export const simpleData = { menu, index, storyId }; export const loadingData = { menu }; +const managerContext: any = { + state: { + docsOptions: { + defaultName: 'Docs', + autodocs: 'tag', + docsMode: false, + }, + }, + api: { + emit: fn().mockName('api::emit'), + on: fn().mockName('api::on'), + off: fn().mockName('api::off'), + getShortcutKeys: fn(() => ({ search: ['control', 'shift', 's'] })).mockName( + 'api::getShortcutKeys' + ), + selectStory: fn().mockName('api::selectStory'), + experimental_setFilter: fn().mockName('api::experimental_setFilter'), + }, +}; + const meta = { component: Sidebar, title: 'Sidebar/Sidebar', @@ -44,28 +64,7 @@ const meta = { }, decorators: [ (storyFn) => ( - ({ search: ['control', 'shift', 's'] })).mockName( - 'api::getShortcutKeys' - ), - selectStory: fn().mockName('api::selectStory'), - }, - } as any - } - > + {storyFn()} @@ -218,41 +217,29 @@ export const Searching: Story = { }; export const Bottom: Story = { - args: { - bottom: [ - { - id: '1', - type: types.experimental_SIDEBAR_BOTTOM, - render: () => ( - - ), - }, - { - id: '2', - type: types.experimental_SIDEBAR_BOTTOM, - render: () => ( - - ), - }, - { - id: '3', - type: types.experimental_SIDEBAR_BOTTOM, - render: () => ( - - {' '} - - - ), - }, - ], - }, + decorators: [ + (storyFn) => ( + + {storyFn()} + + ), + ], }; /** diff --git a/code/core/src/manager/components/sidebar/Sidebar.tsx b/code/core/src/manager/components/sidebar/Sidebar.tsx index 583c11518fc9..55b5af5f0b42 100644 --- a/code/core/src/manager/components/sidebar/Sidebar.tsx +++ b/code/core/src/manager/components/sidebar/Sidebar.tsx @@ -20,6 +20,7 @@ import { SearchResults } from './SearchResults'; import type { CombinedDataset, Selection } from './types'; import { useLastViewed } from './useLastViewed'; import { MEDIA_DESKTOP_BREAKPOINT } from '../../constants'; +import { SidebarBottom } from './SidebarBottom'; export const DEFAULT_REF_ID = 'storybook_internal'; @@ -109,7 +110,6 @@ export interface SidebarProps extends API_LoadedRefData { status: State['status']; menu: any[]; extra: Addon_SidebarTopType[]; - bottom?: Addon_SidebarBottomType[]; storyId?: string; refId?: string; menuHighlighted?: boolean; @@ -128,7 +128,6 @@ export const Sidebar = React.memo(function Sidebar({ previewInitialized, menu, extra, - bottom = [], menuHighlighted = false, enableShortcuts = true, refs = {}, @@ -194,9 +193,7 @@ export const Sidebar = React.memo(function Sidebar({ {isLoading ? null : ( - {bottom.map(({ id, render: Render }) => ( - - ))} + )} diff --git a/code/core/src/manager/components/sidebar/SidebarBottom.stories.tsx b/code/core/src/manager/components/sidebar/SidebarBottom.stories.tsx new file mode 100644 index 000000000000..498750fd82e0 --- /dev/null +++ b/code/core/src/manager/components/sidebar/SidebarBottom.stories.tsx @@ -0,0 +1,42 @@ +import { fn } from '@storybook/test'; + +import { SidebarBottomBase } from './SidebarBottom'; + +export default { + component: SidebarBottomBase, + args: { + api: { + experimental_setFilter: fn(), + emit: fn(), + }, + }, +}; + +export const Errors = { + args: { + status: { + one: { 'sidebar-bottom-filter': { status: 'error' } }, + two: { 'sidebar-bottom-filter': { status: 'error' } }, + }, + }, +}; + +export const Warnings = { + args: { + status: { + one: { 'sidebar-bottom-filter': { status: 'warn' } }, + two: { 'sidebar-bottom-filter': { status: 'warn' } }, + }, + }, +}; + +export const Both = { + args: { + status: { + one: { 'sidebar-bottom-filter': { status: 'warn' } }, + two: { 'sidebar-bottom-filter': { status: 'warn' } }, + three: { 'sidebar-bottom-filter': { status: 'error' } }, + four: { 'sidebar-bottom-filter': { status: 'error' } }, + }, + }, +}; diff --git a/code/core/src/manager/components/sidebar/SidebarBottom.tsx b/code/core/src/manager/components/sidebar/SidebarBottom.tsx new file mode 100644 index 000000000000..f85c5eaacc8c --- /dev/null +++ b/code/core/src/manager/components/sidebar/SidebarBottom.tsx @@ -0,0 +1,91 @@ +import { + useStorybookApi, + useStorybookState, + type API, + type State, +} from '@storybook/core/manager-api'; +import { styled } from '@storybook/core/theming'; +import type { API_FilterFunction } from '@storybook/types'; +import React, { useCallback, useEffect } from 'react'; + +import { FilterToggle } from './FilterToggle'; + +const filterNone: API_FilterFunction = () => true; +const filterWarn: API_FilterFunction = ({ status = {} }) => + Object.values(status).some((value) => value?.status === 'warn'); +const filterError: API_FilterFunction = ({ status = {} }) => + Object.values(status).some((value) => value?.status === 'error'); +const filterBoth: API_FilterFunction = ({ status = {} }) => + Object.values(status).some((value) => value?.status === 'warn' || value?.status === 'error'); + +const getFilter = (showWarnings = false, showErrors = false) => { + if (showWarnings && showErrors) return filterBoth; + if (showWarnings) return filterWarn; + if (showErrors) return filterError; + return filterNone; +}; + +const Wrapper = styled.div({ + display: 'flex', + gap: 5, +}); + +interface SidebarBottomProps { + api: API; + status: State['status']; +} + +export const SidebarBottomBase = ({ api, status = {} }: SidebarBottomProps) => { + const [showWarnings, setShowWarnings] = React.useState(false); + const [showErrors, setShowErrors] = React.useState(false); + + const warnings = Object.values(status).filter((statusByAddonId) => + Object.values(statusByAddonId).some((value) => value.status === 'warn') + ); + const errors = Object.values(status).filter((statusByAddonId) => + Object.values(statusByAddonId).some((value) => value.status === 'error') + ); + const hasWarnings = warnings.length > 0; + const hasErrors = errors.length > 0; + + const toggleWarnings = useCallback(() => setShowWarnings((shown) => !shown), []); + const toggleErrors = useCallback(() => setShowErrors((shown) => !shown), []); + + useEffect(() => { + const filter = getFilter(hasWarnings && showWarnings, hasErrors && showErrors); + api.experimental_setFilter('sidebar-bottom-filter', filter); + }, [api, hasWarnings, hasErrors, showWarnings, showErrors]); + + if (!hasWarnings && !hasErrors) return null; + + return ( + + {hasErrors && ( + + )} + {hasWarnings && ( + + )} + + ); +}; + +export const SidebarBottom = () => { + const api = useStorybookApi(); + const { status } = useStorybookState(); + return ; +}; diff --git a/code/core/src/manager/container/Sidebar.tsx b/code/core/src/manager/container/Sidebar.tsx index 058361de9afb..9bc6c156b0ba 100755 --- a/code/core/src/manager/container/Sidebar.tsx +++ b/code/core/src/manager/container/Sidebar.tsx @@ -42,11 +42,8 @@ const Sidebar = React.memo(function Sideber({ onMenuClick }: SidebarProps) { const whatsNewNotificationsEnabled = state.whatsNewData?.status === 'SUCCESS' && !state.disableWhatsNewNotifications; - const bottomItems = api.getElements(Addon_TypesEnum.experimental_SIDEBAR_BOTTOM); const topItems = api.getElements(Addon_TypesEnum.experimental_SIDEBAR_TOP); // eslint-disable-next-line react-hooks/exhaustive-deps - const bottom = useMemo(() => Object.values(bottomItems), [Object.keys(bottomItems).join('')]); - // eslint-disable-next-line react-hooks/exhaustive-deps const top = useMemo(() => Object.values(topItems), [Object.keys(topItems).join('')]); return { @@ -63,7 +60,6 @@ const Sidebar = React.memo(function Sideber({ onMenuClick }: SidebarProps) { menu, menuHighlighted: whatsNewNotificationsEnabled && api.isWhatsNewUnread(), enableShortcuts, - bottom, extra: top, }; }; diff --git a/code/core/src/manager/globals/exports.ts b/code/core/src/manager/globals/exports.ts index f83d30717328..74ac509e61e5 100644 --- a/code/core/src/manager/globals/exports.ts +++ b/code/core/src/manager/globals/exports.ts @@ -777,6 +777,7 @@ export default { 'SELECT_STORY', 'SET_CONFIG', 'SET_CURRENT_STORY', + 'SET_FILTER', 'SET_GLOBALS', 'SET_INDEX', 'SET_STORIES', @@ -833,6 +834,7 @@ export default { 'SELECT_STORY', 'SET_CONFIG', 'SET_CURRENT_STORY', + 'SET_FILTER', 'SET_GLOBALS', 'SET_INDEX', 'SET_STORIES', @@ -889,6 +891,7 @@ export default { 'SELECT_STORY', 'SET_CONFIG', 'SET_CURRENT_STORY', + 'SET_FILTER', 'SET_GLOBALS', 'SET_INDEX', 'SET_STORIES', diff --git a/code/core/src/types/modules/addons.ts b/code/core/src/types/modules/addons.ts index cc9d90251c2c..708261d2626f 100644 --- a/code/core/src/types/modules/addons.ts +++ b/code/core/src/types/modules/addons.ts @@ -431,6 +431,10 @@ export interface Addon_WrapperType { }> >; } + +/** + * @deprecated This doesn't do anything anymore and will be removed in Storybook 9.0. + */ export interface Addon_SidebarBottomType { type: Addon_TypesEnum.experimental_SIDEBAR_BOTTOM; /** @@ -443,6 +447,9 @@ export interface Addon_SidebarBottomType { render: FC; } +/** + * @deprecated This will be removed in Storybook 9.0. + */ export interface Addon_SidebarTopType { type: Addon_TypesEnum.experimental_SIDEBAR_TOP; /** @@ -523,12 +530,12 @@ export enum Addon_TypesEnum { experimental_PAGE = 'page', /** * This adds items in the bottom of the sidebar. - * @unstable + * @deprecated This doesn't do anything anymore and will be removed in Storybook 9.0. */ experimental_SIDEBAR_BOTTOM = 'sidebar-bottom', /** * This adds items in the top of the sidebar. - * @unstable This will get replaced with a new API in 8.0, use at your own risk. + * @deprecated This will be removed in Storybook 9.0. */ experimental_SIDEBAR_TOP = 'sidebar-top', }