diff --git a/code/.storybook/preview.tsx b/code/.storybook/preview.tsx index adc5556ffb0c..df899c18e33b 100644 --- a/code/.storybook/preview.tsx +++ b/code/.storybook/preview.tsx @@ -34,6 +34,8 @@ import * as templatePreview from '../core/template/stories/preview'; import '../renderers/react/template/components/index'; import { isChromatic } from './isChromatic'; +sb.mock(import('@storybook/global'), { spy: true }); + sb.mock('../core/template/stories/test/ModuleMocking.utils.ts'); sb.mock('../core/template/stories/test/ModuleSpyMocking.utils.ts', { spy: true }); sb.mock('../core/template/stories/test/ModuleAutoMocking.utils.ts'); diff --git a/code/core/src/core-server/presets/common-manager.ts b/code/core/src/core-server/presets/common-manager.ts index bd4c358e837b..9ce7d3219a05 100644 --- a/code/core/src/core-server/presets/common-manager.ts +++ b/code/core/src/core-server/presets/common-manager.ts @@ -1,7 +1,4 @@ /* these imports are in the exact order in which the panels need to be registered */ -import { global } from '@storybook/global'; - -import { addons, Tag } from 'storybook/manager-api'; // THE ORDER OF THESE IMPORTS MATTERS! IT DEFINES THE ORDER OF PANELS AND TOOLS! import controlsManager from '../../controls/manager'; @@ -12,36 +9,8 @@ import measureManager from '../../measure/manager'; import outlineManager from '../../outline/manager'; import viewportManager from '../../viewport/manager'; -const TAG_FILTERS = 'tag-filters'; -const STATIC_FILTER = 'static-filter'; - -const tagFiltersManager = addons.register(TAG_FILTERS, (api) => { - // FIXME: this ensures the filter is applied after the first render - // to avoid a strange race condition in Webkit only. - const staticExcludeTags = Object.entries(global.TAGS_OPTIONS ?? {}).reduce( - (acc, entry) => { - const [tag, option] = entry; - if ((option as any).excludeFromSidebar) { - acc[tag] = true; - } - return acc; - }, - {} as Record - ); - - api.experimental_setFilter(STATIC_FILTER, (item) => { - const tags = item.tags ?? []; - return ( - // we can filter out the primary story, but we still want to show autodocs - (tags.includes(Tag.DEV) || item.type === 'docs') && - tags.filter((tag) => staticExcludeTags[tag]).length === 0 - ); - }); -}); - export default [ measureManager, - tagFiltersManager, actionsManager, backgroundsManager, componentTestingManager, diff --git a/code/core/src/manager-api/modules/layout.ts b/code/core/src/manager-api/modules/layout.ts index a5b47b2b432f..9c1b33ef2119 100644 --- a/code/core/src/manager-api/modules/layout.ts +++ b/code/core/src/manager-api/modules/layout.ts @@ -121,30 +121,36 @@ export interface SubAPI { type PartialSubState = Partial; -export const defaultLayoutState: SubState = { - ui: { - enableShortcuts: true, - }, - layout: { - initialActive: ActiveTabs.CANVAS, - showToolbar: true, - navSize: 300, - bottomPanelHeight: 300, - rightPanelWidth: 400, - recentVisibleSizes: { - navSize: 300, - bottomPanelHeight: 300, - rightPanelWidth: 400, +export const DEFAULT_NAV_SIZE = 300; +export const DEFAULT_BOTTOM_PANEL_HEIGHT = 300; +export const DEFAULT_RIGHT_PANEL_WIDTH = 400; + +export const getDefaultLayoutState: () => SubState = () => { + return { + ui: { + enableShortcuts: true, + }, + layout: { + initialActive: ActiveTabs.CANVAS, + showToolbar: true, + navSize: DEFAULT_NAV_SIZE, + bottomPanelHeight: DEFAULT_BOTTOM_PANEL_HEIGHT, + rightPanelWidth: DEFAULT_RIGHT_PANEL_WIDTH, + recentVisibleSizes: { + navSize: DEFAULT_NAV_SIZE, + bottomPanelHeight: DEFAULT_BOTTOM_PANEL_HEIGHT, + rightPanelWidth: DEFAULT_RIGHT_PANEL_WIDTH, + }, + panelPosition: 'bottom', + showTabs: true, }, - panelPosition: 'bottom', - showTabs: true, - }, - layoutCustomisations: { - showSidebar: undefined, - showToolbar: undefined, - }, - selectedPanel: undefined, - theme: create(), + layoutCustomisations: { + showSidebar: undefined, + showToolbar: undefined, + }, + selectedPanel: undefined, + theme: create(), + }; }; export const focusableUIElements = { @@ -434,6 +440,7 @@ export const init: ModuleFn = ({ store, provider, singleStory getInitialOptions() { const { theme, selectedPanel, layoutCustomisations, ...options } = provider.getConfig(); + const defaultLayoutState = getDefaultLayoutState(); return { ...defaultLayoutState, diff --git a/code/core/src/manager-api/modules/stories.ts b/code/core/src/manager-api/modules/stories.ts index 30a9b769eef9..dfd4e08e7f6c 100644 --- a/code/core/src/manager-api/modules/stories.ts +++ b/code/core/src/manager-api/modules/stories.ts @@ -28,6 +28,7 @@ import type { API_IndexHash, API_LeafEntry, API_LoadedRefData, + API_PreparedIndexEntry, API_PreparedStoryIndex, API_StoryEntry, API_TestEntry, @@ -35,16 +36,22 @@ import type { Args, ComponentTitle, DocsPreparedPayload, + FilterFunction, SetStoriesPayload, StoryId, StoryIndex, StoryKind, StoryName, StoryPreparedPayload, + Tag, + TagsOptions, } from 'storybook/internal/types'; import { global } from '@storybook/global'; +import memoize from 'memoizerific'; + +import { BUILT_IN_FILTERS, Tag as TagEnum, USER_TAG_FILTER } from '../../shared/constants/tags'; import { getEventMetadata } from '../lib/events'; import { addPreparedStories, @@ -60,6 +67,83 @@ import { fullStatusStore } from '../stores/status'; const { fetch } = global; const STORY_INDEX_PATH = './index.json'; +const TAGS_FILTER = 'tags-filter'; +const STATIC_FILTER = 'static-filter'; + +export const getDefaultTagsFromPreset = memoize(1)( + ( + presets: TagsOptions + ): { + included: Tag[]; + excluded: Tag[]; + } => { + const presetEntries = Object.entries(presets); + return { + included: presetEntries + .filter(([, option]) => option.defaultFilterSelection === 'include') + .map(([tag]) => tag), + excluded: presetEntries + .filter(([, option]) => option.defaultFilterSelection === 'exclude') + .map(([tag]) => tag), + }; + } +); + +const computeStaticFilterFn = (tagPresets: TagsOptions) => { + const staticExcludeTags = Object.entries(tagPresets).reduce( + (acc, entry) => { + const [tag, option] = entry; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((option as any).excludeFromSidebar) { + acc[tag] = true; + } + return acc; + }, + {} as Record + ); + + return (item: API_PreparedIndexEntry) => { + const tags = item.tags ?? []; + return ( + (tags.includes(TagEnum.DEV) || item.type === 'docs') && + tags.filter((tag) => staticExcludeTags[tag]).length === 0 + ); + }; +}; + +const computeTagsFilterFn = ( + includedTagFilters: Tag[], + excludedTagFilters: Tag[] +): ((item: API_PreparedIndexEntry) => boolean) => { + const computeFilterFunctions = (set: Tag[]): FilterFunction[][] => { + return Object.values( + set.reduce( + (acc, tag) => { + if (Object.hasOwn(BUILT_IN_FILTERS, tag)) { + acc['built-in'].push(BUILT_IN_FILTERS[tag as keyof typeof BUILT_IN_FILTERS]); + } else { + acc.user.push(USER_TAG_FILTER(tag)); + } + return acc; + }, + { 'built-in': [], user: [] } as { 'built-in': FilterFunction[]; user: FilterFunction[] } + ) + ).filter((group) => group.length > 0); + }; + + return (item: API_PreparedIndexEntry) => { + const included = computeFilterFunctions(includedTagFilters); + const excluded = computeFilterFunctions(excludedTagFilters); + + return ( + (!included.length || + included.every((group) => group.some((filterFn) => filterFn(item, false)))) && + (!excluded.length || + excluded.every((group) => group.every((filterFn) => filterFn(item, true)))) + ); + }; +}; + type Direction = -1 | 1; type ParameterName = string; @@ -74,6 +158,11 @@ export interface SubState extends API_LoadedRefData { internal_index?: API_PreparedStoryIndex; viewMode: API_ViewMode; filters: Record; + tagPresets: TagsOptions; + defaultIncludedTagFilters: Tag[]; + defaultExcludedTagFilters: Tag[]; + includedTagFilters: Tag[]; + excludedTagFilters: Tag[]; } export interface SubAPI { @@ -291,6 +380,30 @@ export interface SubAPI { * @returns {Promise} A promise that resolves when the state has been updated. */ experimental_setFilter: (addonId: string, filterFunction: API_FilterFunction) => Promise; + + /** Resets tag filters in the sidebar to the default filters. */ + resetTagFilters(): void; + /** + * Replaces all tag filters in the sidebar with the provided included and excluded lists. + * + * @param included The tags to include in the filtered stories list + * @param excluded The tags to filter out (exclude) from the stories list + */ + setAllTagFilters(included: Tag[], excluded: Tag[]): void; + /** + * Adds tag filters to the included or excluded filter lists. Included filters are included in the + * stories list, whereas excluded filters are filtered out. + * + * @param tags The tags to add as filters. + * @param excluded Whether to add the tags to the include or exclude filter list. + */ + addTagFilters(tags: Tag[], excluded: boolean): void; + /** + * Removes tag filters from both the included and excluded filter lists. + * + * @param tags The tags to remove from filters. + */ + removeTagFilters(tags: Tag[]): void; } const removedOptions = ['enableShortcuts', 'theme', 'showRoots']; @@ -711,6 +824,70 @@ export const init: ModuleFn = ({ provider.channel?.emit(SET_FILTER, { id }); }, + resetTagFilters: async () => { + await store.setState( + (s) => ({ + includedTagFilters: s.defaultIncludedTagFilters, + excludedTagFilters: s.defaultExcludedTagFilters, + }), + { persistence: 'permanent' } + ); + recomputeFilters(); + }, + + setAllTagFilters: async (included: Tag[], excluded: Tag[]) => { + await store.setState( + { + includedTagFilters: included, + excludedTagFilters: excluded, + }, + { persistence: 'permanent' } + ); + recomputeFilters(); + }, + + addTagFilters: async (tags: Tag[], excluded: boolean) => { + const state = store.getState(); + const newIncluded = new Set(state.includedTagFilters); + const newExcluded = new Set(state.excludedTagFilters); + for (const tag of tags) { + if (excluded) { + newIncluded.delete(tag); + newExcluded.add(tag); + } else { + newIncluded.add(tag); + newExcluded.delete(tag); + } + } + await store.setState( + { + includedTagFilters: Array.from(newIncluded), + excludedTagFilters: Array.from(newExcluded), + }, + { persistence: 'permanent' } + ); + recomputeFilters(); + }, + + removeTagFilters: async (tags: Tag[]) => { + const state = store.getState(); + await store.setState( + { + includedTagFilters: state.includedTagFilters.filter((tag) => !tags.includes(tag)), + excludedTagFilters: state.excludedTagFilters.filter((tag) => !tags.includes(tag)), + }, + { persistence: 'permanent' } + ); + recomputeFilters(); + }, + }; + + const recomputeFilters = () => { + const { includedTagFilters, excludedTagFilters } = store.getState(); + api.experimental_setFilter( + TAGS_FILTER, + computeTagsFilterFn(includedTagFilters, excludedTagFilters) + ); }; // On initial load, the local iframe will select the first story (or other "selection specifier") @@ -908,14 +1085,18 @@ export const init: ModuleFn = ({ provider.channel?.on(SET_CONFIG, () => { const config = provider.getConfig(); - if (config?.sidebar?.filters) { - store.setState({ - filters: { - ...store.getState().filters, - ...config?.sidebar?.filters, - }, - }); - } + const configFilters = config?.sidebar?.filters || {}; + const { includedTagFilters, excludedTagFilters, tagPresets } = store.getState(); + + // Config sidebar filters first, then our managed filters override any conflicts + store.setState({ + filters: { + ...store.getState().filters, + ...configFilters, + [STATIC_FILTER]: computeStaticFilterFn(tagPresets), + [TAGS_FILTER]: computeTagsFilterFn(includedTagFilters, excludedTagFilters), + }, + }); }); fullStatusStore.onAllStatusChange(async () => { @@ -936,6 +1117,30 @@ export const init: ModuleFn = ({ }); const config = provider.getConfig(); + const configFilters = config?.sidebar?.filters || {}; + + // Compute default tag filter values from presets + const tagPresets: TagsOptions = global.TAGS_OPTIONS || {}; + const defaultTags = getDefaultTagsFromPreset(tagPresets); + + // Read persisted tag filter state, supporting migration from the old layout.xxx path + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const persistedState = store.getState() as Record; + const initialIncluded: Tag[] = + persistedState.includedTagFilters ?? + persistedState.layout?.includedTagFilters ?? + defaultTags.included; + const initialExcluded: Tag[] = + persistedState.excludedTagFilters ?? + persistedState.layout?.excludedTagFilters ?? + defaultTags.excluded; + + // Build initial filters: config sidebar filters first, then our managed filters take priority + const initialFilters: Record = { + ...configFilters, + [STATIC_FILTER]: computeStaticFilterFn(tagPresets), + [TAGS_FILTER]: computeTagsFilterFn(initialIncluded, initialExcluded), + }; return { api, @@ -944,7 +1149,12 @@ export const init: ModuleFn = ({ viewMode: initialViewMode, hasCalledSetOptions: false, previewInitialized: false, - filters: config?.sidebar?.filters || {}, + filters: initialFilters, + tagPresets, + defaultIncludedTagFilters: defaultTags.included, + defaultExcludedTagFilters: defaultTags.excluded, + includedTagFilters: initialIncluded, + excludedTagFilters: initialExcluded, }, init: async () => { provider.channel?.on(STORY_INDEX_INVALIDATED, () => api.fetchIndex()); diff --git a/code/core/src/manager-api/modules/url.ts b/code/core/src/manager-api/modules/url.ts index e46494a34cf4..faa74bfb351c 100644 --- a/code/core/src/manager-api/modules/url.ts +++ b/code/core/src/manager-api/modules/url.ts @@ -17,7 +17,7 @@ import { stringify } from 'picoquery'; import merge from '../lib/merge'; import type { ModuleArgs, ModuleFn } from '../lib/types'; -import { defaultLayoutState } from './layout'; +import { DEFAULT_BOTTOM_PANEL_HEIGHT, DEFAULT_NAV_SIZE, DEFAULT_RIGHT_PANEL_WIDTH } from './layout'; export interface SubState { customQueryParams: QueryParams; @@ -87,14 +87,14 @@ const initialUrlSupport = ({ bottomPanelHeight = 0; rightPanelWidth = 0; } else if (parseBoolean(full) === false) { - navSize = defaultLayoutState.layout.navSize; - bottomPanelHeight = defaultLayoutState.layout.bottomPanelHeight; - rightPanelWidth = defaultLayoutState.layout.rightPanelWidth; + navSize = DEFAULT_NAV_SIZE; + bottomPanelHeight = DEFAULT_BOTTOM_PANEL_HEIGHT; + rightPanelWidth = DEFAULT_RIGHT_PANEL_WIDTH; } // set sizes based on nav if (!singleStory) { if (parseBoolean(nav) === true) { - navSize = defaultLayoutState.layout.navSize; + navSize = DEFAULT_NAV_SIZE; } if (parseBoolean(nav) === false) { navSize = 0; diff --git a/code/core/src/manager-api/store.ts b/code/core/src/manager-api/store.ts index ca19eb4f4293..b1071e47002b 100644 --- a/code/core/src/manager-api/store.ts +++ b/code/core/src/manager-api/store.ts @@ -27,7 +27,12 @@ function update(storage: StoreAPI, patch: Patch) { type GetState = () => State; type SetState = (a: any, b: any) => any; -interface Upstream { +export interface Upstream { + /** + * Whether to allow persistence of state to local/sessionStorage. This is used to disable + * persistence in Storybook's own tests. True by default. + */ + allowPersistence?: boolean; getState: GetState; setState: SetState; } @@ -46,11 +51,12 @@ type CallbackOrOptions = CallBack | Options; // Our store piggybacks off the internal React state of the Context Provider // It has been augmented to persist state to local/sessionStorage export default class Store { + upstreamPersistence: boolean; upstreamGetState: GetState; - upstreamSetState: SetState; - constructor({ setState, getState }: Upstream) { + constructor({ allowPersistence, setState, getState }: Upstream) { + this.upstreamPersistence = allowPersistence ?? true; this.upstreamSetState = setState; this.upstreamGetState = getState; } @@ -108,7 +114,7 @@ export default class Store { }); }); - if (persistence !== 'none') { + if (persistence !== 'none' && this.upstreamPersistence) { const storage = persistence === 'session' ? store.session : store.local; await update(storage, delta); } diff --git a/code/core/src/manager-api/test-utils/store.ts b/code/core/src/manager-api/test-utils/store.ts new file mode 100644 index 000000000000..6fb8a13d4ad9 --- /dev/null +++ b/code/core/src/manager-api/test-utils/store.ts @@ -0,0 +1,48 @@ +import type { State } from '../root'; +import Store, { type Upstream } from '../store'; + +/** Store guaranteed not to read from storage, for testing purposes. */ +class InMemoryStore extends Store { + constructor({ setState, getState }: Upstream) { + super({ allowPersistence: false, setState, getState }); + } + + getInitialState(base: State) { + return base; + } +} + +/** + * Factory function to create a valid Store instance for testing purposes. Provides a simple + * in-memory store without persistence logic. Useful for mocking the store in stories. + * + * @param initialState - The initial state for the store + * @param onChange - Optional callback invoked whenever state changes + * @returns A Store instance configured for testing + */ +export function createTestingStore( + initialState: State, + onChange?: (internalState: State) => void +): Store { + let internalState = { ...initialState }; + + const upstream = { + allowPersistence: false, + getState: () => internalState, + setState: (patch: any, callback?: any) => { + if (typeof patch === 'function') { + internalState = { ...internalState, ...patch(internalState) }; + } else { + internalState = { ...internalState, ...patch }; + } + if (callback && typeof callback === 'function') { + callback(internalState); + } + if (onChange) { + onChange(internalState); + } + }, + }; + + return new InMemoryStore(upstream); +} diff --git a/code/core/src/manager-api/tests/layout.test.ts b/code/core/src/manager-api/tests/layout.test.ts index 0c382d586455..8bd96d737b8e 100644 --- a/code/core/src/manager-api/tests/layout.test.ts +++ b/code/core/src/manager-api/tests/layout.test.ts @@ -9,7 +9,7 @@ import { themes } from 'storybook/theming'; import type { ModuleArgs } from '../lib/types'; import type { SubState as AddonsSubState } from '../modules/addons'; import type { SubAPI, SubState } from '../modules/layout'; -import { defaultLayoutState, init as initLayout } from '../modules/layout'; +import { getDefaultLayoutState, init as initLayout } from '../modules/layout'; import type { API, State } from '../root'; import type Store from '../store'; @@ -24,7 +24,7 @@ describe('layout API', () => { beforeEach(() => { currentState = { - ...defaultLayoutState, + ...getDefaultLayoutState(), selectedPanel: 'storybook/internal/action/panel', theme: themes.light, singleStory: false, diff --git a/code/core/src/manager-api/tests/stories.test.ts b/code/core/src/manager-api/tests/stories.test.ts index ea2e43e79b9f..3bdbeec046fd 100644 --- a/code/core/src/manager-api/tests/stories.test.ts +++ b/code/core/src/manager-api/tests/stories.test.ts @@ -1458,7 +1458,15 @@ describe('stories API', () => { expect(state).toEqual( expect.objectContaining({ - filters: {}, + defaultExcludedTagFilters: expect.arrayContaining([]), + defaultIncludedTagFilters: expect.arrayContaining([]), + excludedTagFilters: expect.arrayContaining([]), + includedTagFilters: expect.arrayContaining([]), + filters: expect.objectContaining({ + 'static-filter': expect.any(Function), + 'tags-filter': expect.any(Function), + }), + tagPresets: expect.objectContaining({}), }) ); }); diff --git a/code/core/src/manager/components/sidebar/Sidebar.stories.tsx b/code/core/src/manager/components/sidebar/Sidebar.stories.tsx index bf4e3f4db5b1..eaa7908c500a 100644 --- a/code/core/src/manager/components/sidebar/Sidebar.stories.tsx +++ b/code/core/src/manager/components/sidebar/Sidebar.stories.tsx @@ -33,13 +33,14 @@ const storyId = 'root-1-child-a2--grandchild-a1-1'; export const simpleData = { menu, index, storyId }; export const loadingData = { menu }; -const managerContext: any = { +const managerContext: any = (args: Meta['args']) => ({ state: { docsOptions: { defaultName: 'Docs', autodocs: 'tag', docsMode: false, }, + internal_index: args?.indexJson, }, api: { emit: fn().mockName('api::emit'), @@ -66,7 +67,7 @@ const managerContext: any = { }), applyQueryParams: fn().mockName('api::applyQueryParams'), }, -}; +}); const meta = { component: Sidebar, @@ -100,8 +101,8 @@ const meta = { isDevelopment: true, }, decorators: [ - (storyFn, { globals, title }) => ( - + (storyFn, { args, globals, title }) => ( + ; -const mobileLayoutDecorator: DecoratorFunction = (storyFn, { globals, title }) => ( - +const mobileLayoutDecorator: DecoratorFunction = (storyFn, { args, globals, title }) => ( + - Object.entries(global.TAGS_OPTIONS ?? {}).reduce((acc, entry) => { - const [tag, option] = entry; - acc[tag] = option; - return acc; - }, {} as TagsOptions), - [] - ); - const headerRef = useRef(null); const { landmarkProps } = useLandmark( { 'aria-labelledby': 'global-site-h1', role: 'banner' }, @@ -200,9 +190,7 @@ export const Sidebar = React.memo(function Sidebar({ ) } - searchFieldContent={ - indexJson && - } + searchFieldContent={} {...lastViewedProps} > {({ diff --git a/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx b/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx index e73c3a6e8200..35d51c4faf00 100644 --- a/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx @@ -1,117 +1,318 @@ +import React, { useMemo, useState } from 'react'; + +import type { DocsIndexEntry, StoryIndex, StoryIndexEntry } from 'storybook/internal/types'; + +import { global } from '@storybook/global'; + import type { Meta, StoryObj } from '@storybook/react-vite'; -import { findByRole, fn } from 'storybook/test'; +import { ManagerContext } from 'storybook/manager-api'; +import { expect, screen, waitFor } from 'storybook/test'; import { TagsFilter } from './TagsFilter'; +const getDefaultTagFilters = () => { + const tagOptions = global.TAGS_OPTIONS ?? {}; + + return Object.entries(tagOptions).reduce( + (acc, [tag, option]) => { + if (option.defaultFilterSelection === 'include') { + acc.included.push(tag); + } + if (option.defaultFilterSelection === 'exclude') { + acc.excluded.push(tag); + } + return acc; + }, + { included: [] as string[], excluded: [] as string[] } + ); +}; + +const createInitialState = (initialStoryState: Record = {}) => { + const defaults = getDefaultTagFilters(); + + const defaultIncludedTagFilters = + (initialStoryState.defaultIncludedTagFilters as string[] | undefined) ?? defaults.included; + const defaultExcludedTagFilters = + (initialStoryState.defaultExcludedTagFilters as string[] | undefined) ?? defaults.excluded; + + return { + ...initialStoryState, + defaultIncludedTagFilters, + defaultExcludedTagFilters, + includedTagFilters: + (initialStoryState.includedTagFilters as string[] | undefined) ?? defaultIncludedTagFilters, + excludedTagFilters: + (initialStoryState.excludedTagFilters as string[] | undefined) ?? defaultExcludedTagFilters, + }; +}; + const meta = { component: TagsFilter, title: 'Sidebar/TagsFilter', tags: ['haha', 'this-is-a-very-long-tag-that-will-be-truncated-after-a-while'], - args: { - api: { - experimental_setFilter: fn(), - getDocsUrl: () => 'https://storybook.js.org/docs/', - getUrlState: () => ({ - queryParams: {}, - path: '', - viewMode: 'story', - url: 'http://localhost:6006/', - }), - applyQueryParams: fn().mockName('api::applyQueryParams'), - } as any, - tagPresets: {}, - indexJson: { - v: 6, - entries: { - 'c1-s1': { tags: ['A', 'B', 'C', 'dev', 'play-fn'], type: 'story' } as any, - 'c1-test': { tags: ['test-fn'], type: 'story', subtype: 'test' } as any, - 'c1-doc': { tags: [], type: 'docs' } as any, - }, + decorators: [ + (Story, { args, parameters }) => { + const [state, setState] = useState(() => + createInitialState(parameters?.initialStoryState as Record | undefined) + ); + + const api = useMemo( + () => ({ + addTagFilters: (tags: string[], excluded: boolean) => { + setState((current: any) => { + const includedTagFilters = new Set(current.includedTagFilters ?? []); + const excludedTagFilters = new Set(current.excludedTagFilters ?? []); + + tags.forEach((tag) => { + if (excluded) { + includedTagFilters.delete(tag); + excludedTagFilters.add(tag); + } else { + includedTagFilters.add(tag); + excludedTagFilters.delete(tag); + } + }); + + return { + ...current, + includedTagFilters: Array.from(includedTagFilters), + excludedTagFilters: Array.from(excludedTagFilters), + }; + }); + }, + removeTagFilters: (tags: string[]) => { + setState((current: any) => ({ + ...current, + includedTagFilters: (current.includedTagFilters ?? []).filter( + (tag: string) => !tags.includes(tag) + ), + excludedTagFilters: (current.excludedTagFilters ?? []).filter( + (tag: string) => !tags.includes(tag) + ), + })); + }, + resetTagFilters: () => { + setState((current: any) => ({ + ...current, + includedTagFilters: current.defaultIncludedTagFilters ?? [], + excludedTagFilters: current.defaultExcludedTagFilters ?? [], + })); + }, + setAllTagFilters: (included: string[], excluded: string[]) => { + setState((current: any) => ({ + ...current, + includedTagFilters: included, + excludedTagFilters: excluded, + })); + }, + getDocsUrl: ({ subpath }: { subpath: string }) => + `https://storybook.js.org/docs/${subpath}`, + }), + [] + ); + + return ( + + + + ); }, - }, + ], } satisfies Meta; export default meta; type Story = StoryObj; -export const Closed: Story = {}; +export const Closed: Story = { + parameters: { + initialStoryState: { + internal_index: { + v: 6, + entries: { + 'c1-s1': { tags: ['A', 'B', 'C', 'dev', 'play-fn'], type: 'story' } as StoryIndexEntry, + 'c1-test': { tags: ['test-fn'], type: 'story', subtype: 'test' } as StoryIndexEntry, + 'c1-doc': { tags: [], type: 'docs' } as unknown as DocsIndexEntry, + }, + } as StoryIndex, + }, + }, +}; -export const ClosedWithSelection: Story = { - args: { - ...Closed.args, - tagPresets: { +export const ClosedWithDefaultTags: Story = { + ...Closed, + beforeEach: () => { + const originalTagsOptions = global.TAGS_OPTIONS; + global.TAGS_OPTIONS = { A: { defaultFilterSelection: 'include' }, B: { defaultFilterSelection: 'include' }, + }; + + return () => { + global.TAGS_OPTIONS = originalTagsOptions; + }; + }, +}; + +export const ClosedWithSelection: Story = { + parameters: { + initialStoryState: { + internal_index: { + v: 6, + entries: { + 'c1-s1': { tags: ['A', 'B', 'C', 'dev', 'play-fn'], type: 'story' } as StoryIndexEntry, + 'c1-test': { tags: ['test-fn'], type: 'story', subtype: 'test' } as StoryIndexEntry, + 'c1-doc': { tags: [], type: 'docs' } as unknown as DocsIndexEntry, + }, + } as StoryIndex, + includedTagFilters: ['A', 'B'], }, }, }; export const Clear = { - ...Closed, - play: async ({ canvasElement }) => { - const button = await findByRole(canvasElement, 'button', {}, { timeout: 3000 }); + ...ClosedWithSelection, + play: async ({ canvas }) => { + const button = await canvas.findByRole('button', {}, { timeout: 3000 }); button.click(); + + const clearButton = await screen.findByRole('button', { name: 'Clear filters' }); + + expect(clearButton).toBeInTheDocument(); + clearButton.click(); + await waitFor(() => expect(clearButton).not.toBeInTheDocument()); + }, +} satisfies Story; + +export const ResetToDefaults: Story = { + ...ClosedWithDefaultTags, + parameters: { + initialStoryState: { + internal_index: { + v: 6, + entries: { + 'c1-s1': { tags: ['A', 'B', 'C', 'dev', 'play-fn'], type: 'story' } as StoryIndexEntry, + 'c1-test': { tags: ['test-fn'], type: 'story', subtype: 'test' } as StoryIndexEntry, + 'c1-doc': { tags: [], type: 'docs' } as unknown as DocsIndexEntry, + }, + } as StoryIndex, + excludedTagFilters: ['A', 'B', 'C'], + }, + }, + play: async ({ canvas }) => { + const button = await canvas.findByRole('button', {}, { timeout: 3000 }); + button.click(); + + const resetButton = await screen.findByRole('button', { name: 'Reset filters' }); + + expect(resetButton).toBeInTheDocument(); + expect(resetButton).not.toBeDisabled(); + resetButton.click(); + await waitFor(() => expect(resetButton).toBeDisabled()); }, } satisfies Story; export const NoUserTags = { - ...Clear, - args: { - ...Clear.args, - indexJson: { - v: 6, - entries: { - 'c1-s1': { tags: ['dev', 'play-fn'], type: 'story' } as any, - 'c1-test': { tags: ['test-fn'], type: 'story', subtype: 'test' } as any, - 'c1-doc': { tags: [], type: 'docs' } as any, - }, + parameters: { + initialStoryState: { + internal_index: { + v: 6, + entries: { + 'c1-s1': { tags: ['dev', 'play-fn'], type: 'story' } as StoryIndexEntry, + 'c1-test': { tags: ['test-fn'], type: 'story', subtype: 'test' } as StoryIndexEntry, + 'c1-doc': { tags: [], type: 'docs' } as unknown as DocsIndexEntry, + }, + } as StoryIndex, }, }, + play: async ({ canvas }) => { + const button = await canvas.findByRole('button', {}, { timeout: 3000 }); + button.click(); + + const learnLink = await screen.findByRole('link', { name: 'Learn how to add tags' }); + + expect(learnLink).toBeInTheDocument(); + }, } satisfies Story; export const WithSelection = { ...ClosedWithSelection, - play: Clear.play, + play: async ({ canvas }) => { + const button = await canvas.findByRole('button', {}, { timeout: 3000 }); + button.click(); + }, } satisfies Story; export const WithSelectionInverted = { ...Clear, - args: { - ...Clear.args, - tagPresets: { - A: { defaultFilterSelection: 'exclude' }, - B: { defaultFilterSelection: 'exclude' }, + parameters: { + initialStoryState: { + internal_index: { + v: 6, + entries: { + 'c1-s1': { tags: ['A', 'B', 'C', 'dev', 'play-fn'], type: 'story' } as StoryIndexEntry, + 'c1-test': { tags: ['test-fn'], type: 'story', subtype: 'test' } as StoryIndexEntry, + 'c1-doc': { tags: [], type: 'docs' } as unknown as DocsIndexEntry, + }, + } as StoryIndex, + excludedTagFilters: ['A', 'B'], }, }, + play: async ({ canvas }) => { + const button = await canvas.findByRole('button', {}, { timeout: 3000 }); + button.click(); + }, } satisfies Story; export const WithSelectionMixed = { - ...Clear, - args: { - ...Clear.args, - tagPresets: { - A: { defaultFilterSelection: 'include' }, - B: { defaultFilterSelection: 'exclude' }, + parameters: { + initialStoryState: { + internal_index: { + v: 6, + entries: { + 'c1-s1': { tags: ['A', 'B', 'C', 'dev', 'play-fn'], type: 'story' } as StoryIndexEntry, + 'c1-test': { tags: ['test-fn'], type: 'story', subtype: 'test' } as StoryIndexEntry, + 'c1-doc': { tags: [], type: 'docs' } as unknown as DocsIndexEntry, + }, + } as StoryIndex, + includedTagFilters: ['A'], + excludedTagFilters: ['B'], }, }, + play: async ({ canvas }) => { + const button = await canvas.findByRole('button', {}, { timeout: 3000 }); + button.click(); + }, } satisfies Story; export const Empty: Story = { - args: { - indexJson: { - v: 6, - entries: {}, + parameters: { + initialStoryState: { + internal_index: { + v: 6, + entries: {}, + } as StoryIndex, }, }, - play: Clear.play, + play: async ({ canvas }) => { + const button = await canvas.findByRole('button', {}, { timeout: 3000 }); + button.click(); + + const learnButton = await screen.findByText('Learn how to add tags'); + expect(learnButton).toBeInTheDocument(); + }, }; /** Production is equal to development now */ export const EmptyProduction: Story = { - args: { - ...Empty.args, + parameters: { + initialStoryState: { + internal_index: { + v: 6, + entries: {}, + } as StoryIndex, + }, }, - play: Clear.play, + play: Empty.play, }; diff --git a/code/core/src/manager/components/sidebar/TagsFilter.story-helpers.tsx b/code/core/src/manager/components/sidebar/TagsFilter.story-helpers.tsx new file mode 100644 index 000000000000..516baebd8c0a --- /dev/null +++ b/code/core/src/manager/components/sidebar/TagsFilter.story-helpers.tsx @@ -0,0 +1,132 @@ +import React from 'react'; + +import { Channel } from 'storybook/internal/channels'; +import type { API_Provider, DecoratorFunction } from 'storybook/internal/types'; + +import { deepMerge } from '@vitest/utils'; +import type { API, State } from 'storybook/manager-api'; +import { fn } from 'storybook/test'; + +import type { ModuleArgs, ModuleFn } from '../../../manager-api/lib/types'; +import { init as initStories } from '../../../manager-api/modules/stories'; +import { createTestingStore } from '../../../manager-api/test-utils/store'; + +/** Mock API wrapper that forces component updates when store state changes. */ +export class MockAPIWrapper extends React.Component<{ + children: React.ReactNode; + args: Record; + initFn: ModuleFn; + initOptions?: Partial; + initialStoryState?: Partial; +}> { + api: ReturnType['api']; + store: ReturnType; + channel: Channel; + mounted: boolean; + + constructor(props: { + children: React.ReactNode; + args: Record; + initFn: ModuleFn; + initOptions?: Partial; + initialStoryState?: Partial; + }) { + super(props); + + // Set up store. + this.mounted = false; + this.store = createTestingStore({} as State, (newState) => { + if (this.mounted) { + this.setState(newState); + } + }); + + // Mock channel and provider. + this.channel = new Channel({}); + const provider: API_Provider = { + getConfig: () => ({}), + handleAPI: () => {}, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - TSC in CI fails to recognise this is the right Channel type. + channel: this.channel, + }; + + // Mock other submodules we depend on. + const fullAPI = { + experimental_setFilter: fn().mockName('API::experimental_setFilter'), + getRefs: fn().mockName('API::getRefs').mockReturnValue({}), + setRef: fn().mockName('API::setRef'), + updateRef: fn().mockName('API::updateRef'), + setOptions: fn().mockName('API::setOptions'), + } as unknown as API; + + const { api, init, state } = props.initFn({ + fullAPI, + store: this.store, + provider, + location: { search: '' }, + navigate: () => {}, + path: '', + docsOptions: {}, + state: {} as State, + ...(props.initOptions ?? {}), + }); + + // Apply module and initial story states. + if (props.initialStoryState) { + this.store.setState(deepMerge(state as State, props.initialStoryState)); + } else { + this.store.setState(state as State); + } + + // Call module's post init function if it exists. + if (init && typeof init === 'function') { + init(); + } + + this.api = api as API; + } + + componentDidMount() { + this.mounted = true; + } + + componentWillUnmount() { + this.mounted = false; + } + + render() { + const { children, args } = this.props; + return ( + <> + {React.cloneElement(children as React.ReactElement, { + args: { + ...args, + api: { + ...this.api, + getDocsUrl: () => 'https://storybook.js.org/docs/', + getUrlState: () => ({ + queryParams: {}, + path: '', + viewMode: 'story', + url: 'http://localhost:6006/', + }), + applyQueryParams: fn().mockName('api::applyQueryParams'), + }, + }, + })} + + ); + } +} + +export const MockAPIDecorator: DecoratorFunction = (Story, { args, parameters }) => ( + + + +); diff --git a/code/core/src/manager/components/sidebar/TagsFilter.tsx b/code/core/src/manager/components/sidebar/TagsFilter.tsx index 6c8dd4667ba6..86a56a624cd8 100644 --- a/code/core/src/manager/components/sidebar/TagsFilter.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilter.tsx @@ -1,44 +1,25 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { Badge, Button, PopoverProvider } from 'storybook/internal/components'; -import type { API_PreparedIndexEntry, StoryIndex, TagsOptions } from 'storybook/internal/types'; +import type { StoryIndex } from 'storybook/internal/types'; -import { BeakerIcon, DocumentIcon, FilterIcon, PlayHollowIcon } from '@storybook/icons'; +import { FilterIcon } from '@storybook/icons'; -import type { API } from 'storybook/manager-api'; -import { Tag } from 'storybook/manager-api'; -import { color, styled } from 'storybook/theming'; +import { type API, type Combo, Consumer } from 'storybook/manager-api'; +import { styled } from 'storybook/theming'; -import { type Filter, type FilterFunction, TagsFilterPanel, groupByType } from './TagsFilterPanel'; +import { TagsFilterPanel } from './TagsFilterPanel'; -const TAGS_FILTER = 'tags-filter'; - -const BUILT_IN_TAGS = new Set(Object.values(Tag)); - -const StyledButton = styled(Button)<{ isHighlighted: boolean }>(({ isHighlighted, theme }) => ({ +const StyledButton = styled(Button)<{ $isHighlighted: boolean }>(({ $isHighlighted, theme }) => ({ '&:focus-visible': { outlineOffset: 4, }, - ...(isHighlighted && { + ...($isHighlighted && { background: theme.background.hoverable, color: theme.color.secondary, }), })); -// Immutable set operations -const add = (set: Set, id: string) => { - const copy = new Set(set); - copy.add(id); - return copy; -}; -const remove = (set: Set, id: string) => { - const copy = new Set(set); - copy.delete(id); - return copy; -}; -const equal = (left: Set, right: Set) => - left.size === right.size && new Set([...left, ...right]).size === left.size; - const TagSelected = styled(Badge)(({ theme }) => ({ position: 'absolute', top: 7, @@ -57,157 +38,44 @@ const TagSelected = styled(Badge)(({ theme }) => ({ color: theme.color.inverseText, })); -export interface TagsFilterProps { +const tagsFilterMapper = ({ api, state }: Combo) => ({ + api, + indexJson: state.internal_index as StoryIndex | undefined, + activeFilterCount: + (state.includedTagFilters?.length ?? 0) + (state.excludedTagFilters?.length ?? 0), + defaultIncludedFilters: state.defaultIncludedTagFilters, + defaultExcludedFilters: state.defaultExcludedTagFilters, + includedFilters: state.includedTagFilters, + excludedFilters: state.excludedTagFilters, +}); + +interface TagsFilterInnerProps { api: API; indexJson: StoryIndex; - tagPresets: TagsOptions; + activeFilterCount: number; + defaultIncludedFilters: string[]; + defaultExcludedFilters: string[]; + includedFilters: string[]; + excludedFilters: string[]; } -export const TagsFilter = ({ api, indexJson, tagPresets }: TagsFilterProps) => { - const filtersById = useMemo<{ [id: string]: Filter }>(() => { - const userTagsCounts = Object.values(indexJson.entries).reduce<{ [key: Tag]: number }>( - (acc, entry) => { - entry.tags?.forEach((tag: Tag) => { - if (!BUILT_IN_TAGS.has(tag)) { - acc[tag] = (acc[tag] || 0) + 1; - } - }); - return acc; - }, - {} - ); - - const userFilters = Object.fromEntries( - Object.entries(userTagsCounts).map(([tag, count]) => { - const filterFn = (entry: API_PreparedIndexEntry, excluded?: boolean) => - excluded ? !entry.tags?.includes(tag) : !!entry.tags?.includes(tag); - return [tag, { id: tag, type: 'tag', title: tag, count, filterFn }]; - }) - ); - - const withCount = (filterFn: FilterFunction) => ({ - count: Object.values(indexJson.entries).filter((entry) => filterFn(entry)).length, - filterFn, - }); - - const builtInFilters = { - _docs: { - id: '_docs', - type: 'built-in', - title: 'Documentation', - icon: , - ...withCount((entry: API_PreparedIndexEntry, excluded?: boolean) => - excluded ? entry.type !== 'docs' : entry.type === 'docs' - ), - }, - _play: { - id: '_play', - type: 'built-in', - title: 'Play', - icon: , - ...withCount((entry: API_PreparedIndexEntry, excluded?: boolean) => - excluded - ? entry.type !== 'story' || !entry.tags?.includes(Tag.PLAY_FN) - : entry.type === 'story' && !!entry.tags?.includes(Tag.PLAY_FN) - ), - }, - _test: { - id: '_test', - type: 'built-in', - title: 'Testing', - icon: , - ...withCount((entry: API_PreparedIndexEntry, excluded?: boolean) => - excluded - ? entry.type !== 'story' || entry.subtype !== 'test' - : entry.type === 'story' && entry.subtype === 'test' - ), - }, - }; - - return { ...userFilters, ...builtInFilters }; - }, [indexJson.entries]); - - const { defaultIncluded, defaultExcluded } = useMemo(() => { - return Object.entries(tagPresets).reduce( - (acc, [tag, { defaultFilterSelection }]) => { - if (defaultFilterSelection === 'include') { - acc.defaultIncluded.add(tag); - } else if (defaultFilterSelection === 'exclude') { - acc.defaultExcluded.add(tag); - } - return acc; - }, - { defaultIncluded: new Set(), defaultExcluded: new Set() } - ); - }, [tagPresets]); - - const [includedFilters, setIncludedFilters] = useState(new Set(defaultIncluded)); - const [excludedFilters, setExcludedFilters] = useState(new Set(defaultExcluded)); +const TagsFilterInner = ({ + api, + indexJson, + activeFilterCount, + defaultIncludedFilters, + defaultExcludedFilters, + includedFilters, + excludedFilters, +}: TagsFilterInnerProps) => { const [expanded, setExpanded] = useState(false); - const tagsActive = includedFilters.size > 0 || excludedFilters.size > 0; - - const resetFilters = useCallback(() => { - setIncludedFilters(new Set(defaultIncluded)); - setExcludedFilters(new Set(defaultExcluded)); - }, [defaultIncluded, defaultExcluded]); - - useEffect(resetFilters, [resetFilters]); - - useEffect(() => { - api.experimental_setFilter(TAGS_FILTER, (item) => { - const included = Object.values( - groupByType(Array.from(includedFilters).map((id) => filtersById[id])) - ); - const excluded = Object.values( - groupByType(Array.from(excludedFilters).map((id) => filtersById[id])) - ); - - return ( - (!included.length || - included.every((group) => group.some(({ filterFn }) => filterFn(item, false)))) && - (!excluded.length || - excluded.every((group) => group.every(({ filterFn }) => filterFn(item, true)))) - ); - }); - }, [api, includedFilters, excludedFilters, filtersById]); - - const toggleFilter = useCallback( - (id: string, selected: boolean, excluded?: boolean) => { - if (excluded === true) { - setExcludedFilters(add(excludedFilters, id)); - setIncludedFilters(remove(includedFilters, id)); - } else if (excluded === false) { - setIncludedFilters(add(includedFilters, id)); - setExcludedFilters(remove(excludedFilters, id)); - } else if (selected) { - setIncludedFilters(add(includedFilters, id)); - setExcludedFilters(remove(excludedFilters, id)); - } else { - setIncludedFilters(remove(includedFilters, id)); - setExcludedFilters(remove(excludedFilters, id)); - } - }, - [includedFilters, excludedFilters] - ); - - const setAllFilters = useCallback( - (selected: boolean) => { - if (selected) { - setIncludedFilters(new Set(Object.keys(filtersById))); - } else { - setIncludedFilters(new Set()); - } - setExcludedFilters(new Set()); - }, - [filtersById] - ); const handleToggleExpand = useCallback( (event: React.SyntheticEvent): void => { event.preventDefault(); setExpanded(!expanded); }, - [expanded, setExpanded] + [expanded] ); return ( @@ -220,31 +88,56 @@ export const TagsFilter = ({ api, indexJson, tagPresets }: TagsFilterProps) => { popover={() => ( 0 || defaultExcluded.size > 0} /> )} > 0} onClick={handleToggleExpand} > - {includedFilters.size + excludedFilters.size > 0 && } + {activeFilterCount > 0 && } ); }; + +export const TagsFilter = () => ( + + {({ + api, + indexJson, + activeFilterCount, + defaultIncludedFilters, + defaultExcludedFilters, + includedFilters, + excludedFilters, + }) => + indexJson ? ( + + ) : null + } + +); diff --git a/code/core/src/manager/components/sidebar/TagsFilterPanel.stories.tsx b/code/core/src/manager/components/sidebar/TagsFilterPanel.stories.tsx index f87eb224b4e4..5640a94d1dee 100644 --- a/code/core/src/manager/components/sidebar/TagsFilterPanel.stories.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilterPanel.stories.tsx @@ -1,79 +1,106 @@ -import { BeakerIcon, DocumentIcon, PlayHollowIcon } from '@storybook/icons'; +import type { DocsIndexEntry, StoryIndex, StoryIndexEntry } from 'storybook/internal/types'; import type { Meta, StoryObj } from '@storybook/react-vite'; -import { fn } from 'storybook/test'; -import { color } from 'storybook/theming'; +import { type API } from 'storybook/manager-api'; +import { MockAPIDecorator } from './TagsFilter.story-helpers'; import { TagsFilterPanel } from './TagsFilterPanel'; -const builtInFilters = { - _docs: { - id: '_docs', - type: 'built-in', - title: 'Documentation', - icon: , - count: 8, - filterFn: fn(), - }, - _play: { - id: '_play', - type: 'built-in', - title: 'Play', - icon: , - count: 21, - filterFn: fn(), - }, - _test: { - id: '_test', - type: 'built-in', - title: 'Testing', - icon: , - count: 42, - filterFn: fn(), - }, +const getEntries = (includeUserTags: boolean) => { + const entries = { + 'c1-autodocs': { tags: ['tag1', 'autodocs'], type: 'docs' } as DocsIndexEntry, + 'c1-story1': { tags: ['tag1', 'dev'], type: 'story' } as StoryIndexEntry, + 'c1-story2': { tags: ['tag1'], type: 'story' } as StoryIndexEntry, + 'c2-autodocs': { tags: ['tag1', 'autodocs'], type: 'docs' } as DocsIndexEntry, + 'c2-story1': { tags: ['tag1', 'play-fn'], type: 'story' } as StoryIndexEntry, + 'c2-story2': { tags: ['tag1'], type: 'story' } as StoryIndexEntry, + 'c2-story3': { tags: ['tag1'], type: 'story' } as StoryIndexEntry, + 'c3-autodocs': { tags: ['tag1', 'autodocs'], type: 'docs' } as DocsIndexEntry, + 'c3-story1': { tags: ['tag1', 'play-fn'], type: 'story' } as StoryIndexEntry, + 'c3-story2': { tags: ['tag1', 'play-fn'], type: 'story' } as StoryIndexEntry, + 'c3-story3': { tags: ['tag1', 'play-fn'], type: 'story' } as StoryIndexEntry, + 'c4-autodocs': { tags: ['tag1', 'autodocs'], type: 'docs' } as DocsIndexEntry, + 'c4-story1': { tags: ['tag1'], type: 'story' } as StoryIndexEntry, + 'c4-story2': { tags: ['tag1'], type: 'story' } as StoryIndexEntry, + 'c5-autodocs': { tags: ['tag2', 'autodocs'], type: 'docs' } as DocsIndexEntry, + 'c5-story1': { tags: ['tag2', 'play-fn'], type: 'story' } as StoryIndexEntry, + 'c5-story2': { tags: ['tag2', 'play-fn'], type: 'story' } as StoryIndexEntry, + 'c5-story3': { tags: ['tag2', 'play-fn'], type: 'story' } as StoryIndexEntry, + 'c6-autodocs': { tags: ['tag2', 'autodocs'], type: 'docs' } as DocsIndexEntry, + 'c6-story1': { tags: ['tag2'], type: 'story' } as StoryIndexEntry, + 'c6-story2': { tags: ['tag2'], type: 'story' } as StoryIndexEntry, + 'c6-story3': { tags: ['tag2'], type: 'story' } as StoryIndexEntry, + 'c7-autodocs': { tags: ['tag2', 'autodocs'], type: 'docs' } as DocsIndexEntry, + 'c7-story1': { tags: ['tag2'], type: 'story' } as StoryIndexEntry, + 'c7-story2': { tags: ['tag2'], type: 'story' } as StoryIndexEntry, + 'c7-story3': { tags: ['tag2'], type: 'story' } as StoryIndexEntry, + 'c8-autodocs': { tags: ['tag2', 'autodocs'], type: 'docs' } as DocsIndexEntry, + 'c8-story1': { tags: ['tag2', 'play-fn'], type: 'story' } as StoryIndexEntry, + 'c8-story2': { tags: ['tag2', 'play-fn'], type: 'story' } as StoryIndexEntry, + 'c8-story3': { tags: ['tag2'], type: 'story' } as StoryIndexEntry, + 'c9-autodocs': { tags: ['tag2', 'autodocs'], type: 'docs' } as DocsIndexEntry, + 'c9-story1': { tags: ['tag2'], type: 'story' } as StoryIndexEntry, + 'c9-story2': { tags: ['tag2', 'play-fn'], type: 'story' } as StoryIndexEntry, + 'c9-story3': { tags: ['tag2', 'play-fn'], type: 'story' } as StoryIndexEntry, + 'c10-autodocs': { tags: ['tag2', 'autodocs'], type: 'docs' } as DocsIndexEntry, + 'c10-story1': { tags: ['tag2', 'play-fn'], type: 'story' } as StoryIndexEntry, + 'c10-story2': { tags: ['tag2', 'play-fn'], type: 'story' } as StoryIndexEntry, + 'c10-story3': { tags: ['tag2', 'play-fn'], type: 'story' } as StoryIndexEntry, + 'c11-story1': { + tags: ['tag3-which-is-very-long-and-will-be-truncated-after-a-while'], + type: 'story', + } as StoryIndexEntry, + 'c11-story2': { + tags: ['tag3-which-is-very-long-and-will-be-truncated-after-a-while'], + type: 'story', + } as StoryIndexEntry, + 'c12-s1-test1': { tags: ['test-fn'], type: 'story', subtype: 'test' } as StoryIndexEntry, + 'c12-s1-test2': { tags: ['test-fn'], type: 'story', subtype: 'test' } as StoryIndexEntry, + 'c12-s1-test3': { tags: ['test-fn'], type: 'story', subtype: 'test' } as StoryIndexEntry, + 'c12-s1-test4': { tags: ['test-fn'], type: 'story', subtype: 'test' } as StoryIndexEntry, + 'c12-s1-test5': { tags: ['test-fn'], type: 'story', subtype: 'test' } as StoryIndexEntry, + 'c12-s1-test6': { tags: ['test-fn'], type: 'story', subtype: 'test' } as StoryIndexEntry, + 'c12-s1-test7': { tags: ['test-fn'], type: 'story', subtype: 'test' } as StoryIndexEntry, + 'c12-s1-test8': { tags: ['test-fn'], type: 'story', subtype: 'test' } as StoryIndexEntry, + 'c12-s3-test1': { tags: ['test-fn'], type: 'story', subtype: 'test' } as StoryIndexEntry, + 'c12-s3-test2': { tags: ['test-fn'], type: 'story', subtype: 'test' } as StoryIndexEntry, + 'c12-s3-test3': { tags: ['test-fn'], type: 'story', subtype: 'test' } as StoryIndexEntry, + 'c12-s3-test4': { tags: ['test-fn'], type: 'story', subtype: 'test' } as StoryIndexEntry, + 'c12-s3-test5': { tags: ['test-fn'], type: 'story', subtype: 'test' } as StoryIndexEntry, + 'c12-s3-test6': { tags: ['test-fn'], type: 'story', subtype: 'test' } as StoryIndexEntry, + 'c12-s3-test7': { tags: ['test-fn'], type: 'story', subtype: 'test' } as StoryIndexEntry, + 'c12-s3-test8': { tags: ['test-fn'], type: 'story', subtype: 'test' } as StoryIndexEntry, + }; + + if (!includeUserTags) { + Object.values(entries).forEach((entry) => { + entry.tags = entry.tags?.filter((tag) => + ['autodocs', 'dev', 'play-fn', 'test-fn'].includes(tag) + ); + }); + } + + return entries; }; const meta = { component: TagsFilterPanel, title: 'Sidebar/TagsFilterPanel', + // Will provide api mock + decorators: [MockAPIDecorator], + tags: ['hoho'], args: { - toggleFilter: fn(), - setAllFilters: fn(), - filtersById: { - tag1: { - id: 'tag1', - type: 'tag', - title: 'Tag1', - count: 11, - filterFn: fn(), - }, - tag2: { - id: 'tag2', - type: 'tag', - title: 'Tag2', - count: 24, - filterFn: fn(), - }, - 'tag3-which-is-very-long-and-will-be-truncated-after-a-while': { - id: 'tag3-which-is-very-long-and-will-be-truncated-after-a-while', - type: 'tag', - title: 'Tag3', - count: 2, - filterFn: fn(), - }, - ...builtInFilters, - }, - includedFilters: new Set(), - excludedFilters: new Set(), - resetFilters: fn(), - isDefaultSelection: true, - hasDefaultSelection: false, - api: { - getDocsUrl: () => 'https://storybook.js.org/docs/', - } as any, + api: {} as API, + indexJson: { + v: 6, + entries: getEntries(true), + } as StoryIndex, + defaultExcludedFilters: [], + defaultIncludedFilters: [], + includedFilters: [], + excludedFilters: [], }, - tags: ['hoho'], } satisfies Meta; export default meta; @@ -84,7 +111,10 @@ export const Basic: Story = {}; export const BuiltInOnly: Story = { args: { - filtersById: builtInFilters, + indexJson: { + v: 6, + entries: getEntries(false), + } as StoryIndex, }, }; @@ -95,44 +125,65 @@ export const BuiltInOnly: Story = { */ export const BuiltInOnlyProduction: Story = { args: { - ...BuiltInOnly.args, + indexJson: { + v: 6, + entries: getEntries(false), + } as StoryIndex, }, }; export const Included: Story = { args: { - includedFilters: new Set(['tag1', '_play']), - isDefaultSelection: false, + indexJson: { + v: 6, + entries: getEntries(true), + } as StoryIndex, + includedFilters: ['tag1'], }, }; export const Excluded: Story = { args: { - excludedFilters: new Set(['tag1', '_play']), - isDefaultSelection: false, + indexJson: { + v: 6, + entries: getEntries(true), + } as StoryIndex, + excludedFilters: ['tag1'], }, }; export const Mixed: Story = { args: { - includedFilters: new Set(['tag1', '_play']), - excludedFilters: new Set(['tag2', '_test']), - isDefaultSelection: false, + indexJson: { + v: 6, + entries: getEntries(true), + } as StoryIndex, + includedFilters: ['tag1'], + excludedFilters: ['tag2'], }, }; export const DefaultSelection: Story = { args: { - ...Mixed.args, - isDefaultSelection: true, - hasDefaultSelection: true, + indexJson: { + v: 6, + entries: getEntries(true), + } as StoryIndex, + includedFilters: ['tag1'], + excludedFilters: ['tag2'], + defaultIncludedFilters: ['tag1'], + defaultExcludedFilters: ['tag2'], }, }; export const DefaultSelectionModified: Story = { args: { - ...Mixed.args, - isDefaultSelection: false, - hasDefaultSelection: true, + indexJson: { + v: 6, + entries: getEntries(true), + } as StoryIndex, + includedFilters: ['tag1', 'tag2'], + defaultIncludedFilters: ['tag1'], + defaultExcludedFilters: ['tag2'], }, }; diff --git a/code/core/src/manager/components/sidebar/TagsFilterPanel.tsx b/code/core/src/manager/components/sidebar/TagsFilterPanel.tsx index fa5ed4914059..cf172dd78a40 100644 --- a/code/core/src/manager/components/sidebar/TagsFilterPanel.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilterPanel.tsx @@ -1,23 +1,33 @@ -import React, { Fragment, useRef } from 'react'; +import React, { Fragment, useCallback, useMemo, useRef } from 'react'; import { ActionList, Form } from 'storybook/internal/components'; -import type { API_PreparedIndexEntry } from 'storybook/internal/types'; +import type { FilterFunction, StoryIndex, Tag } from 'storybook/internal/types'; import { BatchAcceptIcon, + BeakerIcon, DeleteIcon, DocumentIcon, + PlayHollowIcon, ShareAltIcon, SweepIcon, UndoIcon, } from '@storybook/icons'; import type { API } from 'storybook/manager-api'; -import { styled } from 'storybook/theming'; +import { color, styled } from 'storybook/theming'; import type { Link } from '../../../components/components/tooltip/TooltipLinkList'; +import { BUILT_IN_FILTERS, USER_TAG_FILTER } from '../../../shared/constants/tags'; -export const groupByType = (filters: Filter[]) => +type Filter = { + id: string; + type: string; + title: string; + count: number; +}; + +const groupByType = (filters: Filter[]) => filters.filter(Boolean).reduce( (acc, filter) => { acc[filter.type] ??= []; @@ -40,61 +50,148 @@ const MutedText = styled.span(({ theme }) => ({ color: theme.textMutedColor, })); -export type FilterFunction = (entry: API_PreparedIndexEntry, excluded?: boolean) => boolean; -export type Filter = { - id: string; - type: string; - title: string; - count: number; - filterFn: FilterFunction; -}; - interface TagsFilterPanelProps { api: API; - filtersById: { [id: string]: Filter }; - includedFilters: Set; - excludedFilters: Set; - toggleFilter: (key: string, selected: boolean, excluded?: boolean) => void; - setAllFilters: (selected: boolean) => void; - resetFilters: () => void; - isDefaultSelection: boolean; - hasDefaultSelection: boolean; + indexJson: StoryIndex; + defaultIncludedFilters: string[]; + defaultExcludedFilters: string[]; + includedFilters: string[]; + excludedFilters: string[]; } +const BUILT_IN_TAGS = new Set([ + 'dev', + 'test', + 'autodocs', + 'attached-mdx', + 'unattached-mdx', + 'play-fn', + 'test-fn', +]); + +// This equality check works on the basis that there are no duplicates in the arrays. +// We use arrays because we need arrays for data persistence in the layout module. +const equal = (left: string[], right: string[]) => + left.length === right.length && new Set([...left, ...right]).size === left.length; + +const getFilterFunction = (tag: Tag): FilterFunction | null => { + if (Object.hasOwn(BUILT_IN_FILTERS, tag)) { + return BUILT_IN_FILTERS[tag as keyof typeof BUILT_IN_FILTERS]; + } else { + return USER_TAG_FILTER(tag); + } +}; + export const TagsFilterPanel = ({ api, - filtersById, + indexJson, + defaultIncludedFilters, + defaultExcludedFilters, includedFilters, excludedFilters, - toggleFilter, - setAllFilters, - resetFilters, - isDefaultSelection, - hasDefaultSelection, }: TagsFilterPanelProps) => { const ref = useRef(null); - const renderLink = ({ - id, - type, - title, - icon, - count, - }: { - id: string; - type: string; - title: string; - icon?: React.ReactNode; - count: number; - }): Link | undefined => { + const filtersById = useMemo<{ [id: string]: Filter }>(() => { + const userTagsCounts = Object.values(indexJson.entries).reduce<{ [key: Tag]: number }>( + (acc, entry) => { + entry.tags?.forEach((tag: Tag) => { + if (!BUILT_IN_TAGS.has(tag)) { + acc[tag] = (acc[tag] || 0) + 1; + } + }); + return acc; + }, + {} + ); + + const userFilters = Object.fromEntries( + Object.entries(userTagsCounts).map(([tag, count]) => { + return [tag, { id: tag, type: 'tag', title: tag, count }]; + }) + ); + + const getBuiltInCount = (filterFn: FilterFunction | null) => + Object.values(indexJson.entries).filter((entry) => filterFn?.(entry)).length; + + const builtInFilters = { + _docs: { + id: '_docs', + type: 'built-in', + title: 'Documentation', + icon: , + count: getBuiltInCount(getFilterFunction('_docs')), + }, + _play: { + id: '_play', + type: 'built-in', + title: 'Play', + icon: , + count: getBuiltInCount(getFilterFunction('_play')), + }, + _test: { + id: '_test', + type: 'built-in', + title: 'Testing', + icon: , + count: getBuiltInCount(getFilterFunction('_test')), + }, + }; + + return { ...userFilters, ...builtInFilters }; + }, [indexJson.entries]); + + const toggleFilter = useCallback( + (id: string, selected: boolean, excluded?: boolean) => { + if (excluded !== undefined) { + api.addTagFilters([id], excluded); + } else if (selected) { + api.addTagFilters([id], false); + } else { + api.removeTagFilters([id]); + } + }, + [api] + ); + + const setAllFilters = useCallback( + (selected: boolean) => { + api.setAllTagFilters(selected ? Object.keys(filtersById) : [], []); + }, + [api, filtersById] + ); + + const isDefaultSelection = useMemo(() => { + return ( + equal(includedFilters, defaultIncludedFilters) && + equal(excludedFilters, defaultExcludedFilters) + ); + }, [includedFilters, excludedFilters, defaultIncludedFilters, defaultExcludedFilters]); + + const hasDefaultSelection = useMemo(() => { + return defaultIncludedFilters.length > 0 || defaultExcludedFilters.length > 0; + }, [defaultIncludedFilters, defaultExcludedFilters]); + + const builtInFilterIcons = useMemo( + () => ({ + _docs: , + _play: , + _test: , + }), + [] + ); + + const renderLink = ({ id, type, title, count }: Filter): Link | undefined => { const onToggle = (selected: boolean, excluded?: boolean) => toggleFilter(id, selected, excluded); - const isIncluded = includedFilters.has(id); - const isExcluded = excludedFilters.has(id); + const isIncluded = includedFilters.includes(id); + const isExcluded = excludedFilters.includes(id); const isChecked = isIncluded || isExcluded; const toggleLabel = `${type} filter: ${isExcluded ? `exclude ${title}` : title}`; const toggleTooltip = `${isChecked ? 'Remove' : 'Add'} ${type} filter: ${title}`; const invertButtonLabel = `${isExcluded ? 'Include' : 'Exclude'} ${type}: ${title}`; + const icon = + type === 'built-in' ? builtInFilterIcons[id as keyof typeof builtInFilterIcons] : null; // for built-in filters (docs, play, test), don't show if there are no matches if (count === 0 && type === 'built-in') { @@ -147,7 +244,7 @@ export const TagsFilterPanel = ({ const hasItems = links.length > 0; const hasUserTags = Object.values(filtersById).some(({ type }) => type === 'tag'); - const isNothingSelectedYet = includedFilters.size === 0 && excludedFilters.size === 0; + const isNothingSelectedYet = includedFilters.length === 0 && excludedFilters.length === 0; return ( @@ -179,7 +276,7 @@ export const TagsFilterPanel = ({ api.resetTagFilters()} ariaLabel="Reset filters" tooltip="Reset to default selection" disabled={isDefaultSelection} diff --git a/code/core/src/shared/constants/tags.ts b/code/core/src/shared/constants/tags.ts index 5d5a6b11dc47..f17662efe0eb 100644 --- a/code/core/src/shared/constants/tags.ts +++ b/code/core/src/shared/constants/tags.ts @@ -1,3 +1,5 @@ +import type { API_PreparedIndexEntry } from '../../types'; + /** System tags used throughout Storybook for categorizing and filtering stories and docs entries. */ export const Tag = { /** Indicates that autodocs should be generated for this component */ @@ -23,3 +25,27 @@ export const Tag = { * system tags used by Storybook. */ export type Tag = string; + +/** + * Built-in story filters that extend beyond simple tag inclusion/exclusion. Those are used in the + * manager UI and in the manager API stories module. + */ +export const BUILT_IN_FILTERS = { + _docs: (entry: API_PreparedIndexEntry, excluded?: boolean) => + excluded ? entry.type !== 'docs' : entry.type === 'docs', + _play: (entry: API_PreparedIndexEntry, excluded?: boolean) => + excluded + ? entry.type !== 'story' || !entry.tags?.includes(Tag.PLAY_FN) + : entry.type === 'story' && !!entry.tags?.includes(Tag.PLAY_FN), + _test: (entry: API_PreparedIndexEntry, excluded?: boolean) => + excluded + ? entry.type !== 'story' || entry.subtype !== 'test' + : entry.type === 'story' && entry.subtype === 'test', +}; + +/** + * Logic to resolve whether a tag filters a given entry, based on whether the tag is excluded or + * included. Shared by the manager UI and manager API stories module. + */ +export const USER_TAG_FILTER = (tag: Tag) => (entry: API_PreparedIndexEntry, excluded?: boolean) => + excluded ? !entry.tags?.includes(tag) : !!entry.tags?.includes(tag); diff --git a/code/core/src/types/modules/api.ts b/code/core/src/types/modules/api.ts index 2d61616aff85..7e311e88cad4 100644 --- a/code/core/src/types/modules/api.ts +++ b/code/core/src/types/modules/api.ts @@ -5,7 +5,12 @@ import type { State } from '../../manager-api'; import type { RenderData } from '../../router/types'; import type { ThemeVars } from '../../theming/types'; import type { Addon_RenderOptions } from './addons'; -import type { API_FilterFunction, API_HashEntry, API_IndexHash } from './api-stories'; +import type { + API_FilterFunction, + API_HashEntry, + API_IndexHash, + API_PreparedIndexEntry, +} from './api-stories'; import type { SetStoriesStory, SetStoriesStoryData } from './channelApi'; import type { DocsOptions } from './core-common'; import type { StoryIndex } from './indexer'; @@ -69,6 +74,8 @@ export interface API_UIOptions { selectedPanel?: string; } +export type FilterFunction = (entry: API_PreparedIndexEntry, excluded?: boolean) => boolean; + export interface API_Layout { initialActive: API_ActiveTabsType; navSize: number; diff --git a/code/e2e-tests/tags.spec.ts b/code/e2e-tests/tags.spec.ts index dee950505223..f8b92a5b960e 100644 --- a/code/e2e-tests/tags.spec.ts +++ b/code/e2e-tests/tags.spec.ts @@ -157,11 +157,11 @@ test.describe('tags', () => { await expect(stories).toHaveCount(1); // Clear selection - await expect(tagFilterPopover.locator('#deselect-all')).toBeVisible(); - await tagFilterPopover.locator('#deselect-all').click(); + await expect(page.locator('#deselect-all')).toBeVisible(); + await page.locator('#deselect-all').click(); // Checkboxes are not selected anymore - await expect(tagFilterPopover.locator('input[type="checkbox"]:checked')).toHaveCount(0); + await expect(page.locator('input[type="checkbox"]:checked')).toHaveCount(0); }); }); }); diff --git a/code/e2e-tests/util.ts b/code/e2e-tests/util.ts index 8585626528cd..a6336f4f6f97 100644 --- a/code/e2e-tests/util.ts +++ b/code/e2e-tests/util.ts @@ -193,7 +193,9 @@ export class SbPage { } async openTagsFilter() { - const tagFiltersButton = this.page.locator('[aria-label="Tag filters"]'); + const tagFiltersButton = this.page + .locator('[aria-label*="active tag filter"]') + .or(this.page.locator('[aria-label="Tag filters"]')); // FIXME: we might want to strengthen this locator with an aria-label or testid on the dialog. const tooltip = this.page.locator('[role="dialog"]'); const isTooltipVisible = await tooltip.isVisible();