From 49b8341ad0af6b4cce53bb5c47831a106edbf440 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Tue, 30 Dec 2025 13:30:37 +0100 Subject: [PATCH 01/18] UI: Make TagsFilter state part of the layout module --- code/.storybook/preview.tsx | 2 + code/core/src/manager-api/modules/layout.ts | 282 ++++++++++++++++-- code/core/src/manager-api/modules/url.ts | 10 +- code/core/src/manager-api/store.ts | 43 ++- .../core/src/manager-api/tests/layout.test.ts | 4 +- .../manager/components/sidebar/Sidebar.tsx | 16 +- .../components/sidebar/TagsFilter.stories.tsx | 236 ++++++++++++--- .../manager/components/sidebar/TagsFilter.tsx | 196 ++---------- .../sidebar/TagsFilterPanel.stories.tsx | 213 ++++++++----- .../components/sidebar/TagsFilterPanel.tsx | 175 ++++++++--- code/core/src/types/modules/api.ts | 17 +- 11 files changed, 800 insertions(+), 394 deletions(-) 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/manager-api/modules/layout.ts b/code/core/src/manager-api/modules/layout.ts index a0df07067e5b..69b539be3f78 100644 --- a/code/core/src/manager-api/modules/layout.ts +++ b/code/core/src/manager-api/modules/layout.ts @@ -1,21 +1,28 @@ -import { SET_CONFIG } from 'storybook/internal/core-events'; +import { SET_CONFIG, STORY_INDEX_INVALIDATED } from 'storybook/internal/core-events'; import type { API_Layout, API_LayoutCustomisations, API_PanelPositions, + API_PreparedIndexEntry, API_UI, + FilterFunction, + Tag, + TagsOptions, } from 'storybook/internal/types'; import { global } from '@storybook/global'; import { pick, toMerged } from 'es-toolkit/object'; import { isEqual as deepEqual } from 'es-toolkit/predicate'; +import memoize from 'memoizerific'; import type { ThemeVars } from 'storybook/theming'; import { create } from 'storybook/theming/create'; +import { Tag as TagEnum } from '../../shared/constants/tags'; import merge from '../lib/merge'; import type { ModuleFn } from '../lib/types'; import type { State } from '../root'; +import type Store from '../store'; const { document } = global; @@ -35,6 +42,24 @@ export interface SubState { theme: ThemeVars; } +const TAGS_FILTER = 'tags-filter'; + +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(TagEnum.PLAY_FN) + : entry.type === 'story' && !!entry.tags?.includes(TagEnum.PLAY_FN), + _test: (entry: API_PreparedIndexEntry, excluded?: boolean) => + excluded + ? entry.type !== 'story' || entry.subtype !== 'test' + : entry.type === 'story' && entry.subtype === 'test', +}; + +const USER_TAG_FILTER = (tag: Tag) => (entry: API_PreparedIndexEntry, excluded?: boolean) => + excluded ? !entry.tags?.includes(tag) : !!entry.tags?.includes(tag); + export interface SubAPI { /** * Toggles the fullscreen mode of the Storybook UI. @@ -117,34 +142,95 @@ export interface SubAPI { elementId?: string, options?: boolean | { forceFocus?: boolean; select?: boolean; poll?: boolean } ) => boolean | 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; + /** Gets the function to use to filter the index based on a given tag. */ + getFilterFunction(tag: Tag): FilterFunction | null; + /** Gets the default included tag filters. */ + getDefaultIncludedTagFilters(): Tag[]; + /** Gets the default excluded tag filters. */ + getDefaultExcludedTagFilters(): Tag[]; + /** Gets the currently included tag filters. */ + getIncludedTagFilters(): Tag[]; + /** Gets the currently excluded tag filters. */ + getExcludedTagFilters(): Tag[]; } 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, +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), + }; +}); + +export const DEFAULT_NAV_SIZE = 300; +export const DEFAULT_BOTTOM_PANEL_HEIGHT = 300; +export const DEFAULT_RIGHT_PANEL_WIDTH = 400; + +export const getDefaultLayoutState: () => SubState = () => { + // tagPresets is a local copy of global.TAGS_OPTIONS. Neither is expected to change at runtime. + const tagPresets = global.TAGS_OPTIONS || {}; + const defaultTags = getDefaultTagsFromPreset(tagPresets); + + return { + ui: { + enableShortcuts: true, }, - panelPosition: 'bottom', - showTabs: true, - }, - layoutCustomisations: { - showSidebar: undefined, - showToolbar: undefined, - }, - selectedPanel: undefined, - theme: create(), + layout: { + initialActive: ActiveTabs.CANVAS, + tagPresets, + includedTagFilters: defaultTags.included, + excludedTagFilters: defaultTags.excluded, + 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, + }, + layoutCustomisations: { + showSidebar: undefined, + showToolbar: undefined, + }, + selectedPanel: undefined, + theme: create(), + }; }; export const focusableUIElements = { @@ -184,7 +270,41 @@ const getRecentVisibleSizes = (layoutState: API_Layout) => { }; }; -export const init: ModuleFn = ({ store, provider, singleStory }) => { +const recomputeFilters = (fullAPI: Parameters[0]['fullAPI'], store: Store) => { + const { + layout: { includedTagFilters, excludedTagFilters }, + } = store.getState(); + + const computeFilterFunctions = (set: Tag[]): FilterFunction[][] => { + return Object.values( + set.reduce( + (acc, tag) => { + if (tag in BUILT_IN_FILTERS) { + 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); + }; + + fullAPI.experimental_setFilter?.(TAGS_FILTER, (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)))) + ); + }); +}; + +export const init: ModuleFn = ({ fullAPI, store, provider, singleStory }) => { const api = { toggleFullscreen(nextState?: boolean) { return store.setState( @@ -432,6 +552,7 @@ export const init: ModuleFn = ({ store, provider, singleStory getInitialOptions() { const { theme, selectedPanel, layoutCustomisations, ...options } = provider.getConfig(); + const defaultLayoutState = getDefaultLayoutState(); return { ...defaultLayoutState, @@ -541,6 +662,110 @@ export const init: ModuleFn = ({ store, provider, singleStory store.setState({ theme: updatedTheme }); } }, + + getDefaultIncludedTagFilters: () => { + const state = store.getState(); + const { tagPresets } = state.layout; + return getDefaultTagsFromPreset(tagPresets).included; + }, + + getDefaultExcludedTagFilters: () => { + const state = store.getState(); + const { tagPresets } = state.layout; + return getDefaultTagsFromPreset(tagPresets).excluded; + }, + + getIncludedTagFilters: () => { + const state = store.getState(); + return state.layout.includedTagFilters; + }, + + getExcludedTagFilters: () => { + const state = store.getState(); + return state.layout.excludedTagFilters; + }, + + resetTagFilters: async () => { + const state = store.getState(); + const { tagPresets } = state.layout; + const { included, excluded } = getDefaultTagsFromPreset(tagPresets); + await store.setState( + (s: State) => ({ + layout: { + ...s.layout, + includedTagFilters: included, + excludedTagFilters: excluded, + }, + }), + { persistence: 'permanent' } + ); + recomputeFilters(fullAPI, store); + }, + + setAllTagFilters: async (included: Tag[], excluded: Tag[]) => { + await store.setState( + (s: State) => ({ + layout: { + ...s.layout, + includedTagFilters: included, + excludedTagFilters: excluded, + }, + }), + { persistence: 'permanent' } + ); + recomputeFilters(fullAPI, store); + }, + + addTagFilters: async (tags: Tag[], excluded: boolean) => { + await store.setState( + (s: State) => { + const newIncluded = new Set(s.layout.includedTagFilters); + const newExcluded = new Set(s.layout.excludedTagFilters); + for (const tag of tags) { + if (excluded) { + newIncluded.delete(tag); + newExcluded.add(tag); + } else { + newIncluded.add(tag); + newExcluded.delete(tag); + } + } + return { + layout: { + ...s.layout, + includedTagFilters: Array.from(newIncluded), + excludedTagFilters: Array.from(newExcluded), + }, + }; + }, + { persistence: 'permanent' } + ); + recomputeFilters(fullAPI, store); + }, + + removeTagFilters: async (tags: Tag[]) => { + await store.setState( + (s: State) => { + return { + layout: { + ...s.layout, + includedTagFilters: s.layout.includedTagFilters.filter((tag) => !tags.includes(tag)), + excludedTagFilters: s.layout.excludedTagFilters.filter((tag) => !tags.includes(tag)), + }, + }; + }, + { persistence: 'permanent' } + ); + recomputeFilters(fullAPI, store); + }, + + getFilterFunction(tag: Tag): FilterFunction | null { + if (tag in BUILT_IN_FILTERS) { + return BUILT_IN_FILTERS[tag as keyof typeof BUILT_IN_FILTERS]; + } else { + return USER_TAG_FILTER(tag); + } + }, }; const persisted = pick(store.getState(), ['layout', 'selectedPanel']); @@ -549,8 +774,15 @@ export const init: ModuleFn = ({ store, provider, singleStory api.setOptions(merge(api.getInitialOptions(), persisted)); }); + provider.channel?.on(STORY_INDEX_INVALIDATED, () => { + recomputeFilters(fullAPI, store); + }); + return { api, state: merge(api.getInitialOptions(), persisted), + init: () => { + recomputeFilters(fullAPI, store); + }, }; }; 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..de9c7acf6438 100644 --- a/code/core/src/manager-api/store.ts +++ b/code/core/src/manager-api/store.ts @@ -28,6 +28,7 @@ type GetState = () => State; type SetState = (a: any, b: any) => any; interface Upstream { + allowPersistence?: boolean; getState: GetState; setState: SetState; } @@ -46,11 +47,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 +110,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); } @@ -120,3 +122,38 @@ export default class Store { return newState; } } + +/** + * 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 Store(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/components/sidebar/Sidebar.tsx b/code/core/src/manager/components/sidebar/Sidebar.tsx index 6981f8701ec4..1ac66dd6b58c 100644 --- a/code/core/src/manager/components/sidebar/Sidebar.tsx +++ b/code/core/src/manager/components/sidebar/Sidebar.tsx @@ -1,7 +1,7 @@ import React, { useMemo, useRef, useState } from 'react'; import { Button, ScrollArea } from 'storybook/internal/components'; -import type { API_LoadedRefData, StoryIndex, TagsOptions } from 'storybook/internal/types'; +import type { API_LoadedRefData, StoryIndex } from 'storybook/internal/types'; import type { StatusesByStoryIdAndTypeId } from 'storybook/internal/types'; import { global } from '@storybook/global'; @@ -130,16 +130,6 @@ export const Sidebar = React.memo(function Sidebar({ const api = useStorybookApi(); const { viewMode } = api.getUrlState(); - const tagPresets = useMemo( - () => - 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' }, @@ -194,9 +184,7 @@ export const Sidebar = React.memo(function Sidebar({ ) } - searchFieldContent={ - indexJson && - } + searchFieldContent={indexJson && } {...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..2e617d50b6fe 100644 --- a/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx @@ -1,32 +1,148 @@ +import React from 'react'; + +import { Channel } from 'storybook/internal/channels'; +import type { + API_Provider, + DecoratorFunction, + DocsIndexEntry, + StoryIndexEntry, +} from 'storybook/internal/types'; + import type { Meta, StoryObj } from '@storybook/react-vite'; -import { findByRole, fn } from 'storybook/test'; +import { deepMerge } from '@vitest/utils'; +import type { API, State } from 'storybook/manager-api'; +import { expect, fn, screen, waitFor } from 'storybook/test'; +import type { ModuleArgs, ModuleFn } from '../../../manager-api/lib/types'; +import { init as initLayout } from '../../../manager-api/modules/layout'; +import { createTestingStore } from '../../../manager-api/store'; import { TagsFilter } from './TagsFilter'; +/** 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: () => {}, + channel: this.channel, + }; + + // Mock other submodules we depend on. + const fullAPI = { + experimental_setFilter: fn().mockName('API::experimental_setFilter'), + } 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; + this.state = this.store.getState(); + } + + componentDidMount() { + this.mounted = true; + } + + 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 }) => ( + + + +); + const meta = { component: TagsFilter, title: 'Sidebar/TagsFilter', tags: ['haha', 'this-is-a-very-long-tag-that-will-be-truncated-after-a-while'], + decorators: [MockAPIDecorator], 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: {}, + api: {} as API, // Will be overridden by MockAPIWrapper 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, + '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, }, }, }, @@ -38,21 +154,68 @@ type Story = StoryObj; export const Closed: Story = {}; -export const ClosedWithSelection: Story = { - args: { - ...Closed.args, - tagPresets: { +export const ClosedWithDefaultTags: Story = { + 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: { + layout: { + includedTagFilters: ['A', 'B'], + }, }, }, }; +// We can't properly test resetting to default, because resetting goes through +// global.TAGS_OPTIONS, which I didn't manage to mock. Setting defaultIncludedTagFilters +// still causes the API resetTagFilters function to reset based on the global rather than +// the initial state mocked in the story. + 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: { + layout: { + 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; @@ -63,9 +226,9 @@ export const NoUserTags = { 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, + '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, }, }, }, @@ -78,22 +241,23 @@ export const WithSelection = { export const WithSelectionInverted = { ...Clear, - args: { - ...Clear.args, - tagPresets: { - A: { defaultFilterSelection: 'exclude' }, - B: { defaultFilterSelection: 'exclude' }, + parameters: { + initialStoryState: { + layout: { + excludedTagFilters: ['A', 'B'], + }, }, }, } satisfies Story; export const WithSelectionMixed = { ...Clear, - args: { - ...Clear.args, - tagPresets: { - A: { defaultFilterSelection: 'include' }, - B: { defaultFilterSelection: 'exclude' }, + parameters: { + initialStoryState: { + layout: { + includedTagFilters: ['A'], + excludedTagFilters: ['B'], + }, }, }, } satisfies Story; diff --git a/code/core/src/manager/components/sidebar/TagsFilter.tsx b/code/core/src/manager/components/sidebar/TagsFilter.tsx index 6c8dd4667ba6..87416106aa5a 100644 --- a/code/core/src/manager/components/sidebar/TagsFilter.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilter.tsx @@ -1,19 +1,14 @@ -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 { styled } from 'storybook/theming'; -import { type Filter, type FilterFunction, TagsFilterPanel, groupByType } from './TagsFilterPanel'; - -const TAGS_FILTER = 'tags-filter'; - -const BUILT_IN_TAGS = new Set(Object.values(Tag)); +import { TagsFilterPanel } from './TagsFilterPanel'; const StyledButton = styled(Button)<{ isHighlighted: boolean }>(({ isHighlighted, theme }) => ({ '&:focus-visible': { @@ -25,20 +20,6 @@ const StyledButton = styled(Button)<{ isHighlighted: boolean }>(({ isHighlighted }), })); -// 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, @@ -60,147 +41,14 @@ const TagSelected = styled(Badge)(({ theme }) => ({ export interface TagsFilterProps { api: API; indexJson: StoryIndex; - tagPresets: TagsOptions; } -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]); +export const TagsFilter = ({ api, indexJson }: TagsFilterProps) => { + const includedFilters = api.getIncludedTagFilters(); + const excludedFilters = api.getExcludedTagFilters(); - const [includedFilters, setIncludedFilters] = useState(new Set(defaultIncluded)); - const [excludedFilters, setExcludedFilters] = useState(new Set(defaultExcluded)); 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 activeFilterCount = includedFilters.length + excludedFilters.length; const handleToggleExpand = useCallback( (event: React.SyntheticEvent): void => { @@ -217,33 +65,23 @@ export const TagsFilter = ({ api, indexJson, tagPresets }: TagsFilterProps) => { onVisibleChange={setExpanded} offset={8} padding={0} - popover={() => ( - 0 || defaultExcluded.size > 0} - /> - )} + popover={() => } > - {includedFilters.size + excludedFilters.size > 0 && } + {activeFilterCount > 0 && } ); diff --git a/code/core/src/manager/components/sidebar/TagsFilterPanel.stories.tsx b/code/core/src/manager/components/sidebar/TagsFilterPanel.stories.tsx index f87eb224b4e4..75a9e916f46f 100644 --- a/code/core/src/manager/components/sidebar/TagsFilterPanel.stories.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilterPanel.stories.tsx @@ -1,77 +1,99 @@ -import { BeakerIcon, DocumentIcon, PlayHollowIcon } from '@storybook/icons'; +import type { DocsIndexEntry, 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.stories'; 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', + decorators: [MockAPIDecorator], 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, + api: {} as API, // Will be overridden by MockAPIWrapper + indexJson: { + v: 6, + entries: getEntries(true), }, - includedFilters: new Set(), - excludedFilters: new Set(), - resetFilters: fn(), - isDefaultSelection: true, - hasDefaultSelection: false, - api: { - getDocsUrl: () => 'https://storybook.js.org/docs/', - } as any, }, tags: ['hoho'], } satisfies Meta; @@ -84,7 +106,10 @@ export const Basic: Story = {}; export const BuiltInOnly: Story = { args: { - filtersById: builtInFilters, + indexJson: { + v: 6, + entries: getEntries(false), + }, }, }; @@ -100,39 +125,67 @@ export const BuiltInOnlyProduction: Story = { }; export const Included: Story = { - args: { - includedFilters: new Set(['tag1', '_play']), - isDefaultSelection: false, + parameters: { + initialStoryState: { + layout: { + includedTagFilters: ['tag1'], + }, + }, }, }; export const Excluded: Story = { - args: { - excludedFilters: new Set(['tag1', '_play']), - isDefaultSelection: false, + parameters: { + initialStoryState: { + layout: { + excludedTagFilters: ['tag1'], + }, + }, }, }; export const Mixed: Story = { - args: { - includedFilters: new Set(['tag1', '_play']), - excludedFilters: new Set(['tag2', '_test']), - isDefaultSelection: false, + parameters: { + initialStoryState: { + layout: { + includedTagFilters: ['tag1'], + excludedTagFilters: ['tag2'], + }, + }, }, }; export const DefaultSelection: Story = { - args: { - ...Mixed.args, - isDefaultSelection: true, - hasDefaultSelection: true, + beforeEach: () => { + const originalTagsOptions = global.TAGS_OPTIONS; + global.TAGS_OPTIONS = { + tag1: { defaultFilterSelection: 'include' }, + tag2: { defaultFilterSelection: 'exclude' }, + }; + + return () => { + global.TAGS_OPTIONS = originalTagsOptions; + }; }, }; export const DefaultSelectionModified: Story = { - args: { - ...Mixed.args, - isDefaultSelection: false, - hasDefaultSelection: true, + beforeEach: () => { + const originalTagsOptions = global.TAGS_OPTIONS; + global.TAGS_OPTIONS = { + tag1: { defaultFilterSelection: 'include' }, + tag2: { defaultFilterSelection: 'exclude' }, + }; + + return () => { + global.TAGS_OPTIONS = originalTagsOptions; + }; + }, + parameters: { + initialStoryState: { + layout: { + includedTagFilters: ['tag1', 'tag2'], + }, + }, }, }; diff --git a/code/core/src/manager/components/sidebar/TagsFilterPanel.tsx b/code/core/src/manager/components/sidebar/TagsFilterPanel.tsx index fa5ed4914059..fbd95a652b18 100644 --- a/code/core/src/manager/components/sidebar/TagsFilterPanel.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilterPanel.tsx @@ -1,22 +1,31 @@ -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'; +type Filter = { + id: string; + type: string; + title: string; + count: number; +}; + export const groupByType = (filters: Filter[]) => filters.filter(Boolean).reduce( (acc, filter) => { @@ -40,61 +49,131 @@ 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; } -export const TagsFilterPanel = ({ - api, - filtersById, - includedFilters, - excludedFilters, - toggleFilter, - setAllFilters, - resetFilters, - isDefaultSelection, - hasDefaultSelection, -}: TagsFilterPanelProps) => { +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; + +export const TagsFilterPanel = ({ api, indexJson }: 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 defaultIncluded = api.getDefaultIncludedTagFilters(); + const defaultExcluded = api.getDefaultExcludedTagFilters(); + const includedFilters = api.getIncludedTagFilters(); + const excludedFilters = api.getExcludedTagFilters(); + + 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(api.getFilterFunction('_docs')), + }, + _play: { + id: '_play', + type: 'built-in', + title: 'Play', + icon: , + count: getBuiltInCount(api.getFilterFunction('_play')), + }, + _test: { + id: '_test', + type: 'built-in', + title: 'Testing', + icon: , + count: getBuiltInCount(api.getFilterFunction('_test')), + }, + }; + + return { ...userFilters, ...builtInFilters }; + }, [api, 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, defaultIncluded) && equal(excludedFilters, defaultExcluded); + }, [includedFilters, excludedFilters, defaultIncluded, defaultExcluded]); + + const hasDefaultSelection = useMemo(() => { + return defaultIncluded.length > 0 || defaultExcluded.length > 0; + }, [defaultIncluded, defaultExcluded]); + + 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 +226,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 +258,7 @@ export const TagsFilterPanel = ({ api.resetTagFilters()} ariaLabel="Reset filters" tooltip="Reset to default selection" disabled={isDefaultSelection} diff --git a/code/core/src/types/modules/api.ts b/code/core/src/types/modules/api.ts index 2d61616aff85..473c641624ac 100644 --- a/code/core/src/types/modules/api.ts +++ b/code/core/src/types/modules/api.ts @@ -5,9 +5,14 @@ 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 { DocsOptions, TagsOptions } from './core-common'; import type { StoryIndex } from './indexer'; type OrString = T | (string & {}); @@ -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; @@ -86,6 +93,12 @@ export interface API_Layout { panelPosition: API_PanelPositions; showTabs: boolean; showToolbar: boolean; + /** Initial tag filters applied when Storybook loads (not accounting for persisted store state). */ + tagPresets: TagsOptions; + /** Tags to include in the filter (entries with this tag are shown). Persisted permanently. */ + includedTagFilters: string[]; + /** Tags to exclude from the filter (entries with this tag are hidden). Persisted permanently. */ + excludedTagFilters: string[]; } export interface API_LayoutCustomisations { From e987318ff5338a0604f0bb1b714216604dbc33bb Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Fri, 23 Jan 2026 11:03:10 +0100 Subject: [PATCH 02/18] Address code quality issues --- code/core/src/manager-api/modules/layout.ts | 4 ++-- code/core/src/manager-api/store.ts | 13 ++++++++++++- .../components/sidebar/TagsFilter.stories.tsx | 12 ++++-------- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/code/core/src/manager-api/modules/layout.ts b/code/core/src/manager-api/modules/layout.ts index 69b539be3f78..c9eb033ae9fa 100644 --- a/code/core/src/manager-api/modules/layout.ts +++ b/code/core/src/manager-api/modules/layout.ts @@ -279,7 +279,7 @@ const recomputeFilters = (fullAPI: Parameters[0]['fullAPI'], store: St return Object.values( set.reduce( (acc, tag) => { - if (tag in BUILT_IN_FILTERS) { + 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)); @@ -760,7 +760,7 @@ export const init: ModuleFn = ({ fullAPI, store, provider, sin }, getFilterFunction(tag: Tag): FilterFunction | null { - if (tag in BUILT_IN_FILTERS) { + if (Object.hasOwn(BUILT_IN_FILTERS, tag)) { return BUILT_IN_FILTERS[tag as keyof typeof BUILT_IN_FILTERS]; } else { return USER_TAG_FILTER(tag); diff --git a/code/core/src/manager-api/store.ts b/code/core/src/manager-api/store.ts index de9c7acf6438..397fc51ecc1e 100644 --- a/code/core/src/manager-api/store.ts +++ b/code/core/src/manager-api/store.ts @@ -123,6 +123,17 @@ export default class 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. @@ -155,5 +166,5 @@ export function createTestingStore( }, }; - return new Store(upstream); + return new InMemoryStore(upstream); } diff --git a/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx b/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx index 2e617d50b6fe..c1d7498633ae 100644 --- a/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx @@ -8,6 +8,8 @@ import type { StoryIndexEntry, } from 'storybook/internal/types'; +import { global } from '@storybook/global'; + import type { Meta, StoryObj } from '@storybook/react-vite'; import { deepMerge } from '@vitest/utils'; @@ -29,7 +31,7 @@ export class MockAPIWrapper extends React.Component<{ }> { api: ReturnType['api']; store: ReturnType; - channel: Channel; + channel: API_Provider['channel']; mounted: boolean; constructor(props: { @@ -50,7 +52,7 @@ export class MockAPIWrapper extends React.Component<{ }); // Mock channel and provider. - this.channel = new Channel({}); + this.channel = new Channel({}) satisfies API_Provider['channel']; const provider: API_Provider = { getConfig: () => ({}), handleAPI: () => {}, @@ -87,7 +89,6 @@ export class MockAPIWrapper extends React.Component<{ } this.api = api as API; - this.state = this.store.getState(); } componentDidMount() { @@ -178,11 +179,6 @@ export const ClosedWithSelection: Story = { }, }; -// We can't properly test resetting to default, because resetting goes through -// global.TAGS_OPTIONS, which I didn't manage to mock. Setting defaultIncludedTagFilters -// still causes the API resetTagFilters function to reset based on the global rather than -// the initial state mocked in the story. - export const Clear = { ...ClosedWithSelection, play: async ({ canvas }) => { From 99cc0164fb88c2e264b7d06eb237b11f35356e2c Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Fri, 23 Jan 2026 11:13:04 +0100 Subject: [PATCH 03/18] Address code quality issues --- .../src/manager/components/sidebar/TagsFilter.stories.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx b/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx index c1d7498633ae..ff8b653b8ce8 100644 --- a/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx @@ -95,6 +95,10 @@ export class MockAPIWrapper extends React.Component<{ this.mounted = true; } + componentWillUnmount() { + this.mounted = false; + } + render() { const { children, args } = this.props; return ( From 14ba070e549200f70908616d71628da7f872989d Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Mon, 2 Mar 2026 13:43:10 +0100 Subject: [PATCH 04/18] Fix stories --- .../components/sidebar/Sidebar.stories.tsx | 2 + .../components/sidebar/TagsFilter.stories.tsx | 143 ++---------------- .../sidebar/TagsFilter.story-helpers.tsx | 126 +++++++++++++++ .../sidebar/TagsFilterPanel.stories.tsx | 4 +- 4 files changed, 143 insertions(+), 132 deletions(-) create mode 100644 code/core/src/manager/components/sidebar/TagsFilter.story-helpers.tsx diff --git a/code/core/src/manager/components/sidebar/Sidebar.stories.tsx b/code/core/src/manager/components/sidebar/Sidebar.stories.tsx index bf4e3f4db5b1..db771c99c9da 100644 --- a/code/core/src/manager/components/sidebar/Sidebar.stories.tsx +++ b/code/core/src/manager/components/sidebar/Sidebar.stories.tsx @@ -51,6 +51,8 @@ const managerContext: any = { getShortcutKeys: fn(() => ({ search: ['control', 'shift', 's'] })).mockName( 'api::getShortcutKeys' ), + getIncludedTagFilters: fn(() => []).mockName('api::getIncludedTagFilters'), + getExcludedTagFilters: fn(() => []).mockName('api::getExcludedTagFilters'), getChannel: fn().mockName('api::getChannel'), getElements: fn(() => ({})), navigate: fn().mockName('api::navigate'), diff --git a/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx b/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx index ff8b653b8ce8..bf2e2bbb3ed8 100644 --- a/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx @@ -1,139 +1,14 @@ -import React from 'react'; - -import { Channel } from 'storybook/internal/channels'; -import type { - API_Provider, - DecoratorFunction, - DocsIndexEntry, - StoryIndexEntry, -} from 'storybook/internal/types'; +import type { DocsIndexEntry, StoryIndexEntry } from 'storybook/internal/types'; import { global } from '@storybook/global'; import type { Meta, StoryObj } from '@storybook/react-vite'; -import { deepMerge } from '@vitest/utils'; -import type { API, State } from 'storybook/manager-api'; -import { expect, fn, screen, waitFor } from 'storybook/test'; +import type { API } from 'storybook/manager-api'; +import { expect, screen, waitFor } from 'storybook/test'; -import type { ModuleArgs, ModuleFn } from '../../../manager-api/lib/types'; -import { init as initLayout } from '../../../manager-api/modules/layout'; -import { createTestingStore } from '../../../manager-api/store'; import { TagsFilter } from './TagsFilter'; - -/** 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: API_Provider['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({}) satisfies API_Provider['channel']; - const provider: API_Provider = { - getConfig: () => ({}), - handleAPI: () => {}, - channel: this.channel, - }; - - // Mock other submodules we depend on. - const fullAPI = { - experimental_setFilter: fn().mockName('API::experimental_setFilter'), - } 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 }) => ( - - - -); +import { MockAPIDecorator } from './TagsFilter.story-helpers'; const meta = { component: TagsFilter, @@ -269,7 +144,13 @@ export const Empty: Story = { entries: {}, }, }, - 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 */ @@ -277,5 +158,5 @@ export const EmptyProduction: Story = { args: { ...Empty.args, }, - 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..016ef19e6ecb --- /dev/null +++ b/code/core/src/manager/components/sidebar/TagsFilter.story-helpers.tsx @@ -0,0 +1,126 @@ +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 initLayout } from '../../../manager-api/modules/layout'; +import { createTestingStore } from '../../../manager-api/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: API_Provider['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({}) satisfies API_Provider['channel']; + const provider: API_Provider = { + getConfig: () => ({}), + handleAPI: () => {}, + channel: this.channel, + }; + + // Mock other submodules we depend on. + const fullAPI = { + experimental_setFilter: fn().mockName('API::experimental_setFilter'), + } 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/TagsFilterPanel.stories.tsx b/code/core/src/manager/components/sidebar/TagsFilterPanel.stories.tsx index 75a9e916f46f..3d043d0e8108 100644 --- a/code/core/src/manager/components/sidebar/TagsFilterPanel.stories.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilterPanel.stories.tsx @@ -1,10 +1,12 @@ import type { DocsIndexEntry, StoryIndexEntry } from 'storybook/internal/types'; +import { global } from '@storybook/global'; + import type { Meta, StoryObj } from '@storybook/react-vite'; import type { API } from 'storybook/manager-api'; -import { MockAPIDecorator } from './TagsFilter.stories'; +import { MockAPIDecorator } from './TagsFilter.story-helpers'; import { TagsFilterPanel } from './TagsFilterPanel'; const getEntries = (includeUserTags: boolean) => { From eb00ffdb8bfcece3d173a889ee2bc694ded03e59 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Wed, 11 Mar 2026 14:39:56 +0100 Subject: [PATCH 05/18] Move all filter logic to stories module --- .../src/core-server/presets/common-manager.ts | 31 -- code/core/src/manager-api/modules/layout.ts | 229 +------------- code/core/src/manager-api/modules/stories.ts | 281 +++++++++++++++++- code/core/src/manager-api/store.ts | 48 +-- code/core/src/manager-api/test-utils/store.ts | 48 +++ .../components/sidebar/TagsFilter.stories.tsx | 18 +- .../sidebar/TagsFilter.story-helpers.tsx | 12 +- .../sidebar/TagsFilterPanel.stories.tsx | 18 +- code/core/src/types/modules/api.ts | 8 +- code/playwright.config.ts | 4 +- 10 files changed, 344 insertions(+), 353 deletions(-) create mode 100644 code/core/src/manager-api/test-utils/store.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 c9eb033ae9fa..766af845c96d 100644 --- a/code/core/src/manager-api/modules/layout.ts +++ b/code/core/src/manager-api/modules/layout.ts @@ -1,28 +1,21 @@ -import { SET_CONFIG, STORY_INDEX_INVALIDATED } from 'storybook/internal/core-events'; +import { SET_CONFIG } from 'storybook/internal/core-events'; import type { API_Layout, API_LayoutCustomisations, API_PanelPositions, - API_PreparedIndexEntry, API_UI, - FilterFunction, - Tag, - TagsOptions, } from 'storybook/internal/types'; import { global } from '@storybook/global'; import { pick, toMerged } from 'es-toolkit/object'; import { isEqual as deepEqual } from 'es-toolkit/predicate'; -import memoize from 'memoizerific'; import type { ThemeVars } from 'storybook/theming'; import { create } from 'storybook/theming/create'; -import { Tag as TagEnum } from '../../shared/constants/tags'; import merge from '../lib/merge'; import type { ModuleFn } from '../lib/types'; import type { State } from '../root'; -import type Store from '../store'; const { document } = global; @@ -42,24 +35,6 @@ export interface SubState { theme: ThemeVars; } -const TAGS_FILTER = 'tags-filter'; - -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(TagEnum.PLAY_FN) - : entry.type === 'story' && !!entry.tags?.includes(TagEnum.PLAY_FN), - _test: (entry: API_PreparedIndexEntry, excluded?: boolean) => - excluded - ? entry.type !== 'story' || entry.subtype !== 'test' - : entry.type === 'story' && entry.subtype === 'test', -}; - -const USER_TAG_FILTER = (tag: Tag) => (entry: API_PreparedIndexEntry, excluded?: boolean) => - excluded ? !entry.tags?.includes(tag) : !!entry.tags?.includes(tag); - export interface SubAPI { /** * Toggles the fullscreen mode of the Storybook UI. @@ -142,76 +117,21 @@ export interface SubAPI { elementId?: string, options?: boolean | { forceFocus?: boolean; select?: boolean; poll?: boolean } ) => boolean | 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; - /** Gets the function to use to filter the index based on a given tag. */ - getFilterFunction(tag: Tag): FilterFunction | null; - /** Gets the default included tag filters. */ - getDefaultIncludedTagFilters(): Tag[]; - /** Gets the default excluded tag filters. */ - getDefaultExcludedTagFilters(): Tag[]; - /** Gets the currently included tag filters. */ - getIncludedTagFilters(): Tag[]; - /** Gets the currently excluded tag filters. */ - getExcludedTagFilters(): Tag[]; } type PartialSubState = Partial; -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), - }; -}); - export const DEFAULT_NAV_SIZE = 300; export const DEFAULT_BOTTOM_PANEL_HEIGHT = 300; export const DEFAULT_RIGHT_PANEL_WIDTH = 400; export const getDefaultLayoutState: () => SubState = () => { - // tagPresets is a local copy of global.TAGS_OPTIONS. Neither is expected to change at runtime. - const tagPresets = global.TAGS_OPTIONS || {}; - const defaultTags = getDefaultTagsFromPreset(tagPresets); - return { ui: { enableShortcuts: true, }, layout: { initialActive: ActiveTabs.CANVAS, - tagPresets, - includedTagFilters: defaultTags.included, - excludedTagFilters: defaultTags.excluded, showToolbar: true, navSize: DEFAULT_NAV_SIZE, bottomPanelHeight: DEFAULT_BOTTOM_PANEL_HEIGHT, @@ -270,41 +190,7 @@ const getRecentVisibleSizes = (layoutState: API_Layout) => { }; }; -const recomputeFilters = (fullAPI: Parameters[0]['fullAPI'], store: Store) => { - const { - layout: { includedTagFilters, excludedTagFilters }, - } = store.getState(); - - 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); - }; - - fullAPI.experimental_setFilter?.(TAGS_FILTER, (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)))) - ); - }); -}; - -export const init: ModuleFn = ({ fullAPI, store, provider, singleStory }) => { +export const init: ModuleFn = ({ store, provider, singleStory }) => { const api = { toggleFullscreen(nextState?: boolean) { return store.setState( @@ -662,110 +548,6 @@ export const init: ModuleFn = ({ fullAPI, store, provider, sin store.setState({ theme: updatedTheme }); } }, - - getDefaultIncludedTagFilters: () => { - const state = store.getState(); - const { tagPresets } = state.layout; - return getDefaultTagsFromPreset(tagPresets).included; - }, - - getDefaultExcludedTagFilters: () => { - const state = store.getState(); - const { tagPresets } = state.layout; - return getDefaultTagsFromPreset(tagPresets).excluded; - }, - - getIncludedTagFilters: () => { - const state = store.getState(); - return state.layout.includedTagFilters; - }, - - getExcludedTagFilters: () => { - const state = store.getState(); - return state.layout.excludedTagFilters; - }, - - resetTagFilters: async () => { - const state = store.getState(); - const { tagPresets } = state.layout; - const { included, excluded } = getDefaultTagsFromPreset(tagPresets); - await store.setState( - (s: State) => ({ - layout: { - ...s.layout, - includedTagFilters: included, - excludedTagFilters: excluded, - }, - }), - { persistence: 'permanent' } - ); - recomputeFilters(fullAPI, store); - }, - - setAllTagFilters: async (included: Tag[], excluded: Tag[]) => { - await store.setState( - (s: State) => ({ - layout: { - ...s.layout, - includedTagFilters: included, - excludedTagFilters: excluded, - }, - }), - { persistence: 'permanent' } - ); - recomputeFilters(fullAPI, store); - }, - - addTagFilters: async (tags: Tag[], excluded: boolean) => { - await store.setState( - (s: State) => { - const newIncluded = new Set(s.layout.includedTagFilters); - const newExcluded = new Set(s.layout.excludedTagFilters); - for (const tag of tags) { - if (excluded) { - newIncluded.delete(tag); - newExcluded.add(tag); - } else { - newIncluded.add(tag); - newExcluded.delete(tag); - } - } - return { - layout: { - ...s.layout, - includedTagFilters: Array.from(newIncluded), - excludedTagFilters: Array.from(newExcluded), - }, - }; - }, - { persistence: 'permanent' } - ); - recomputeFilters(fullAPI, store); - }, - - removeTagFilters: async (tags: Tag[]) => { - await store.setState( - (s: State) => { - return { - layout: { - ...s.layout, - includedTagFilters: s.layout.includedTagFilters.filter((tag) => !tags.includes(tag)), - excludedTagFilters: s.layout.excludedTagFilters.filter((tag) => !tags.includes(tag)), - }, - }; - }, - { persistence: 'permanent' } - ); - recomputeFilters(fullAPI, store); - }, - - 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); - } - }, }; const persisted = pick(store.getState(), ['layout', 'selectedPanel']); @@ -774,15 +556,8 @@ export const init: ModuleFn = ({ fullAPI, store, provider, sin api.setOptions(merge(api.getInitialOptions(), persisted)); }); - provider.channel?.on(STORY_INDEX_INVALIDATED, () => { - recomputeFilters(fullAPI, store); - }); - return { api, state: merge(api.getInitialOptions(), persisted), - init: () => { - recomputeFilters(fullAPI, store); - }, }; }; diff --git a/code/core/src/manager-api/modules/stories.ts b/code/core/src/manager-api/modules/stories.ts index 30a9b769eef9..a7a88a451b0c 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 { Tag as TagEnum } from '../../shared/constants/tags'; import { getEventMetadata } from '../lib/events'; import { addPreparedStories, @@ -60,6 +67,99 @@ 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 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(TagEnum.PLAY_FN) + : entry.type === 'story' && !!entry.tags?.includes(TagEnum.PLAY_FN), + _test: (entry: API_PreparedIndexEntry, excluded?: boolean) => + excluded + ? entry.type !== 'story' || entry.subtype !== 'test' + : entry.type === 'story' && entry.subtype === 'test', +}; + +export const USER_TAG_FILTER = (tag: Tag) => (entry: API_PreparedIndexEntry, excluded?: boolean) => + excluded ? !entry.tags?.includes(tag) : !!entry.tags?.includes(tag); + +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 +174,9 @@ export interface SubState extends API_LoadedRefData { internal_index?: API_PreparedStoryIndex; viewMode: API_ViewMode; filters: Record; + tagPresets: TagsOptions; + includedTagFilters: Tag[]; + excludedTagFilters: Tag[]; } export interface SubAPI { @@ -291,6 +394,40 @@ 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; + /** Gets the function to use to filter the index based on a given tag. */ + getFilterFunction(tag: Tag): FilterFunction | null; + /** Gets the default included tag filters. */ + getDefaultIncludedTagFilters(): Tag[]; + /** Gets the default excluded tag filters. */ + getDefaultExcludedTagFilters(): Tag[]; + /** Gets the currently included tag filters. */ + getIncludedTagFilters(): Tag[]; + /** Gets the currently excluded tag filters. */ + getExcludedTagFilters(): Tag[]; } const removedOptions = ['enableShortcuts', 'theme', 'showRoots']; @@ -711,6 +848,101 @@ export const init: ModuleFn = ({ provider.channel?.emit(SET_FILTER, { id }); }, + + getDefaultIncludedTagFilters: () => { + const state = store.getState(); + return getDefaultTagsFromPreset(state.tagPresets).included; + }, + + getDefaultExcludedTagFilters: () => { + const state = store.getState(); + return getDefaultTagsFromPreset(state.tagPresets).excluded; + }, + + getIncludedTagFilters: () => { + const state = store.getState(); + return state.includedTagFilters; + }, + + getExcludedTagFilters: () => { + const state = store.getState(); + return state.excludedTagFilters; + }, + + resetTagFilters: async () => { + const state = store.getState(); + const { included, excluded } = getDefaultTagsFromPreset(state.tagPresets); + await store.setState( + { + includedTagFilters: included, + excludedTagFilters: excluded, + }, + { 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(); + }, + + 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); + } + }, + }; + + 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 +1140,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 +1172,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 +1204,10 @@ export const init: ModuleFn = ({ viewMode: initialViewMode, hasCalledSetOptions: false, previewInitialized: false, - filters: config?.sidebar?.filters || {}, + filters: initialFilters, + tagPresets, + includedTagFilters: initialIncluded, + excludedTagFilters: initialExcluded, }, init: async () => { provider.channel?.on(STORY_INDEX_INVALIDATED, () => api.fetchIndex()); diff --git a/code/core/src/manager-api/store.ts b/code/core/src/manager-api/store.ts index 397fc51ecc1e..8504ebe13932 100644 --- a/code/core/src/manager-api/store.ts +++ b/code/core/src/manager-api/store.ts @@ -27,7 +27,7 @@ function update(storage: StoreAPI, patch: Patch) { type GetState = () => State; type SetState = (a: any, b: any) => any; -interface Upstream { +export interface Upstream { allowPersistence?: boolean; getState: GetState; setState: SetState; @@ -122,49 +122,3 @@ export default class Store { return newState; } } - -/** 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/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/components/sidebar/TagsFilter.stories.tsx b/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx index bf2e2bbb3ed8..595136f5ef9b 100644 --- a/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx @@ -51,9 +51,7 @@ export const ClosedWithDefaultTags: Story = { export const ClosedWithSelection: Story = { parameters: { initialStoryState: { - layout: { - includedTagFilters: ['A', 'B'], - }, + includedTagFilters: ['A', 'B'], }, }, }; @@ -76,9 +74,7 @@ export const ResetToDefaults: Story = { ...ClosedWithDefaultTags, parameters: { initialStoryState: { - layout: { - excludedTagFilters: ['A', 'B', 'C'], - }, + excludedTagFilters: ['A', 'B', 'C'], }, }, play: async ({ canvas }) => { @@ -118,9 +114,7 @@ export const WithSelectionInverted = { ...Clear, parameters: { initialStoryState: { - layout: { - excludedTagFilters: ['A', 'B'], - }, + excludedTagFilters: ['A', 'B'], }, }, } satisfies Story; @@ -129,10 +123,8 @@ export const WithSelectionMixed = { ...Clear, parameters: { initialStoryState: { - layout: { - includedTagFilters: ['A'], - excludedTagFilters: ['B'], - }, + includedTagFilters: ['A'], + excludedTagFilters: ['B'], }, }, } satisfies Story; diff --git a/code/core/src/manager/components/sidebar/TagsFilter.story-helpers.tsx b/code/core/src/manager/components/sidebar/TagsFilter.story-helpers.tsx index 016ef19e6ecb..f62f03686240 100644 --- a/code/core/src/manager/components/sidebar/TagsFilter.story-helpers.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilter.story-helpers.tsx @@ -8,8 +8,8 @@ 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 initLayout } from '../../../manager-api/modules/layout'; -import { createTestingStore } from '../../../manager-api/store'; +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<{ @@ -19,7 +19,7 @@ export class MockAPIWrapper extends React.Component<{ initOptions?: Partial; initialStoryState?: Partial; }> { - api: ReturnType['api']; + api: ReturnType['api']; store: ReturnType; channel: API_Provider['channel']; mounted: boolean; @@ -52,6 +52,10 @@ export class MockAPIWrapper extends React.Component<{ // 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({ @@ -117,7 +121,7 @@ export class MockAPIWrapper extends React.Component<{ export const MockAPIDecorator: DecoratorFunction = (Story, { args, parameters }) => ( diff --git a/code/core/src/manager/components/sidebar/TagsFilterPanel.stories.tsx b/code/core/src/manager/components/sidebar/TagsFilterPanel.stories.tsx index 3d043d0e8108..07d8ec3dc5a2 100644 --- a/code/core/src/manager/components/sidebar/TagsFilterPanel.stories.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilterPanel.stories.tsx @@ -129,9 +129,7 @@ export const BuiltInOnlyProduction: Story = { export const Included: Story = { parameters: { initialStoryState: { - layout: { - includedTagFilters: ['tag1'], - }, + includedTagFilters: ['tag1'], }, }, }; @@ -139,9 +137,7 @@ export const Included: Story = { export const Excluded: Story = { parameters: { initialStoryState: { - layout: { - excludedTagFilters: ['tag1'], - }, + excludedTagFilters: ['tag1'], }, }, }; @@ -149,10 +145,8 @@ export const Excluded: Story = { export const Mixed: Story = { parameters: { initialStoryState: { - layout: { - includedTagFilters: ['tag1'], - excludedTagFilters: ['tag2'], - }, + includedTagFilters: ['tag1'], + excludedTagFilters: ['tag2'], }, }, }; @@ -185,9 +179,7 @@ export const DefaultSelectionModified: Story = { }, parameters: { initialStoryState: { - layout: { - includedTagFilters: ['tag1', 'tag2'], - }, + includedTagFilters: ['tag1', 'tag2'], }, }, }; diff --git a/code/core/src/types/modules/api.ts b/code/core/src/types/modules/api.ts index 473c641624ac..7e311e88cad4 100644 --- a/code/core/src/types/modules/api.ts +++ b/code/core/src/types/modules/api.ts @@ -12,7 +12,7 @@ import type { API_PreparedIndexEntry, } from './api-stories'; import type { SetStoriesStory, SetStoriesStoryData } from './channelApi'; -import type { DocsOptions, TagsOptions } from './core-common'; +import type { DocsOptions } from './core-common'; import type { StoryIndex } from './indexer'; type OrString = T | (string & {}); @@ -93,12 +93,6 @@ export interface API_Layout { panelPosition: API_PanelPositions; showTabs: boolean; showToolbar: boolean; - /** Initial tag filters applied when Storybook loads (not accounting for persisted store state). */ - tagPresets: TagsOptions; - /** Tags to include in the filter (entries with this tag are shown). Persisted permanently. */ - includedTagFilters: string[]; - /** Tags to exclude from the filter (entries with this tag are hidden). Persisted permanently. */ - excludedTagFilters: string[]; } export interface API_LayoutCustomisations { diff --git a/code/playwright.config.ts b/code/playwright.config.ts index af6f071f3c5c..70c4d9d3feb1 100644 --- a/code/playwright.config.ts +++ b/code/playwright.config.ts @@ -4,8 +4,8 @@ import { defineConfig, devices } from '@playwright/test'; // require('dotenv').config(); // Comment this out and fill in the values to run E2E tests locally using the Playwright extension easily -// process.env.STORYBOOK_URL = 'http://localhost:6006'; -// process.env.STORYBOOK_TEMPLATE_NAME = 'react-vite/default-ts'; +process.env.STORYBOOK_URL = 'http://localhost:6006'; +process.env.STORYBOOK_TEMPLATE_NAME = 'react-vite/default-ts'; /** See https://playwright.dev/docs/test-configuration. */ export default defineConfig({ From 87ee27c8344c898baf464d256ed3fe6800065a04 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Wed, 11 Mar 2026 16:07:53 +0100 Subject: [PATCH 06/18] Address doc and TS issues --- code/core/src/manager-api/store.ts | 4 ++++ .../manager/components/sidebar/TagsFilter.story-helpers.tsx | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/code/core/src/manager-api/store.ts b/code/core/src/manager-api/store.ts index 8504ebe13932..b1071e47002b 100644 --- a/code/core/src/manager-api/store.ts +++ b/code/core/src/manager-api/store.ts @@ -28,6 +28,10 @@ type GetState = () => State; type SetState = (a: any, b: any) => any; 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; diff --git a/code/core/src/manager/components/sidebar/TagsFilter.story-helpers.tsx b/code/core/src/manager/components/sidebar/TagsFilter.story-helpers.tsx index f62f03686240..14ba9fbda831 100644 --- a/code/core/src/manager/components/sidebar/TagsFilter.story-helpers.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilter.story-helpers.tsx @@ -42,7 +42,7 @@ export class MockAPIWrapper extends React.Component<{ }); // Mock channel and provider. - this.channel = new Channel({}) satisfies API_Provider['channel']; + this.channel = new Channel({}) as API_Provider['channel']; const provider: API_Provider = { getConfig: () => ({}), handleAPI: () => {}, From 8a39eab82953fa51630ab50882afd45855326782 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Wed, 11 Mar 2026 16:36:06 +0100 Subject: [PATCH 07/18] Rework types again to pass CI type check --- .../manager/components/sidebar/TagsFilter.story-helpers.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/code/core/src/manager/components/sidebar/TagsFilter.story-helpers.tsx b/code/core/src/manager/components/sidebar/TagsFilter.story-helpers.tsx index 14ba9fbda831..c670e35e5f22 100644 --- a/code/core/src/manager/components/sidebar/TagsFilter.story-helpers.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilter.story-helpers.tsx @@ -21,7 +21,7 @@ export class MockAPIWrapper extends React.Component<{ }> { api: ReturnType['api']; store: ReturnType; - channel: API_Provider['channel']; + channel: Channel; mounted: boolean; constructor(props: { @@ -42,7 +42,7 @@ export class MockAPIWrapper extends React.Component<{ }); // Mock channel and provider. - this.channel = new Channel({}) as API_Provider['channel']; + this.channel = new Channel({}); const provider: API_Provider = { getConfig: () => ({}), handleAPI: () => {}, From bd21c773fc4f1ca70b8e7672611337ebaccac237 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Wed, 11 Mar 2026 16:51:35 +0100 Subject: [PATCH 08/18] Restore playwright config file --- code/playwright.config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/code/playwright.config.ts b/code/playwright.config.ts index 70c4d9d3feb1..af6f071f3c5c 100644 --- a/code/playwright.config.ts +++ b/code/playwright.config.ts @@ -4,8 +4,8 @@ import { defineConfig, devices } from '@playwright/test'; // require('dotenv').config(); // Comment this out and fill in the values to run E2E tests locally using the Playwright extension easily -process.env.STORYBOOK_URL = 'http://localhost:6006'; -process.env.STORYBOOK_TEMPLATE_NAME = 'react-vite/default-ts'; +// process.env.STORYBOOK_URL = 'http://localhost:6006'; +// process.env.STORYBOOK_TEMPLATE_NAME = 'react-vite/default-ts'; /** See https://playwright.dev/docs/test-configuration. */ export default defineConfig({ From 758e083df96fce82c6fdd5fc8e81680f24da7c16 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Wed, 11 Mar 2026 17:15:16 +0100 Subject: [PATCH 09/18] Tests: Make locator logic more robust for tags --- code/e2e-tests/tags.spec.ts | 6 +++--- code/e2e-tests/util.ts | 4 +++- 2 files changed, 6 insertions(+), 4 deletions(-) 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(); From 1dde4803e25643c08d80bb377f1ecad969480308 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Thu, 12 Mar 2026 10:17:11 +0100 Subject: [PATCH 10/18] Reinstate highlight logic of old TagsFilter button --- code/core/src/manager/components/sidebar/TagsFilter.tsx | 6 +++--- code/playwright.config.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/code/core/src/manager/components/sidebar/TagsFilter.tsx b/code/core/src/manager/components/sidebar/TagsFilter.tsx index 87416106aa5a..7410fd1f5169 100644 --- a/code/core/src/manager/components/sidebar/TagsFilter.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilter.tsx @@ -10,11 +10,11 @@ import { styled } from 'storybook/theming'; import { TagsFilterPanel } from './TagsFilterPanel'; -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, }), @@ -77,7 +77,7 @@ export const TagsFilter = ({ api, indexJson }: TagsFilterProps) => { ariaDescription="Filter the items shown in a sidebar based on the tags applied to them." variant="ghost" padding="small" - isHighlighted={expanded} + $isHighlighted={activeFilterCount > 0} onClick={handleToggleExpand} > diff --git a/code/playwright.config.ts b/code/playwright.config.ts index af6f071f3c5c..70c4d9d3feb1 100644 --- a/code/playwright.config.ts +++ b/code/playwright.config.ts @@ -4,8 +4,8 @@ import { defineConfig, devices } from '@playwright/test'; // require('dotenv').config(); // Comment this out and fill in the values to run E2E tests locally using the Playwright extension easily -// process.env.STORYBOOK_URL = 'http://localhost:6006'; -// process.env.STORYBOOK_TEMPLATE_NAME = 'react-vite/default-ts'; +process.env.STORYBOOK_URL = 'http://localhost:6006'; +process.env.STORYBOOK_TEMPLATE_NAME = 'react-vite/default-ts'; /** See https://playwright.dev/docs/test-configuration. */ export default defineConfig({ From 792af17d0600ea31e98854b504a641041a4a4589 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Thu, 12 Mar 2026 15:06:00 +0100 Subject: [PATCH 11/18] Avoid API getters that return state --- code/core/src/manager-api/modules/stories.ts | 71 +----- .../components/sidebar/Sidebar.stories.tsx | 2 - .../manager/components/sidebar/Sidebar.tsx | 2 +- .../components/sidebar/TagsFilter.stories.tsx | 217 +++++++++++++++--- .../sidebar/TagsFilter.story-helpers.tsx | 5 +- .../manager/components/sidebar/TagsFilter.tsx | 73 +++++- .../sidebar/TagsFilterPanel.stories.tsx | 99 ++++---- .../components/sidebar/TagsFilterPanel.tsx | 48 ++-- code/core/src/shared/constants/tags.ts | 26 +++ 9 files changed, 376 insertions(+), 167 deletions(-) diff --git a/code/core/src/manager-api/modules/stories.ts b/code/core/src/manager-api/modules/stories.ts index a7a88a451b0c..dfd4e08e7f6c 100644 --- a/code/core/src/manager-api/modules/stories.ts +++ b/code/core/src/manager-api/modules/stories.ts @@ -51,7 +51,7 @@ import { global } from '@storybook/global'; import memoize from 'memoizerific'; -import { Tag as TagEnum } from '../../shared/constants/tags'; +import { BUILT_IN_FILTERS, Tag as TagEnum, USER_TAG_FILTER } from '../../shared/constants/tags'; import { getEventMetadata } from '../lib/events'; import { addPreparedStories, @@ -70,22 +70,6 @@ const STORY_INDEX_PATH = './index.json'; const TAGS_FILTER = 'tags-filter'; const STATIC_FILTER = 'static-filter'; -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(TagEnum.PLAY_FN) - : entry.type === 'story' && !!entry.tags?.includes(TagEnum.PLAY_FN), - _test: (entry: API_PreparedIndexEntry, excluded?: boolean) => - excluded - ? entry.type !== 'story' || entry.subtype !== 'test' - : entry.type === 'story' && entry.subtype === 'test', -}; - -export const USER_TAG_FILTER = (tag: Tag) => (entry: API_PreparedIndexEntry, excluded?: boolean) => - excluded ? !entry.tags?.includes(tag) : !!entry.tags?.includes(tag); - export const getDefaultTagsFromPreset = memoize(1)( ( presets: TagsOptions @@ -175,6 +159,8 @@ export interface SubState extends API_LoadedRefData { viewMode: API_ViewMode; filters: Record; tagPresets: TagsOptions; + defaultIncludedTagFilters: Tag[]; + defaultExcludedTagFilters: Tag[]; includedTagFilters: Tag[]; excludedTagFilters: Tag[]; } @@ -418,16 +404,6 @@ export interface SubAPI { * @param tags The tags to remove from filters. */ removeTagFilters(tags: Tag[]): void; - /** Gets the function to use to filter the index based on a given tag. */ - getFilterFunction(tag: Tag): FilterFunction | null; - /** Gets the default included tag filters. */ - getDefaultIncludedTagFilters(): Tag[]; - /** Gets the default excluded tag filters. */ - getDefaultExcludedTagFilters(): Tag[]; - /** Gets the currently included tag filters. */ - getIncludedTagFilters(): Tag[]; - /** Gets the currently excluded tag filters. */ - getExcludedTagFilters(): Tag[]; } const removedOptions = ['enableShortcuts', 'theme', 'showRoots']; @@ -848,35 +824,12 @@ export const init: ModuleFn = ({ provider.channel?.emit(SET_FILTER, { id }); }, - - getDefaultIncludedTagFilters: () => { - const state = store.getState(); - return getDefaultTagsFromPreset(state.tagPresets).included; - }, - - getDefaultExcludedTagFilters: () => { - const state = store.getState(); - return getDefaultTagsFromPreset(state.tagPresets).excluded; - }, - - getIncludedTagFilters: () => { - const state = store.getState(); - return state.includedTagFilters; - }, - - getExcludedTagFilters: () => { - const state = store.getState(); - return state.excludedTagFilters; - }, - resetTagFilters: async () => { - const state = store.getState(); - const { included, excluded } = getDefaultTagsFromPreset(state.tagPresets); await store.setState( - { - includedTagFilters: included, - excludedTagFilters: excluded, - }, + (s) => ({ + includedTagFilters: s.defaultIncludedTagFilters, + excludedTagFilters: s.defaultExcludedTagFilters, + }), { persistence: 'permanent' } ); recomputeFilters(); @@ -927,14 +880,6 @@ export const init: ModuleFn = ({ ); recomputeFilters(); }, - - 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); - } - }, }; const recomputeFilters = () => { @@ -1206,6 +1151,8 @@ export const init: ModuleFn = ({ previewInitialized: false, filters: initialFilters, tagPresets, + defaultIncludedTagFilters: defaultTags.included, + defaultExcludedTagFilters: defaultTags.excluded, includedTagFilters: initialIncluded, excludedTagFilters: initialExcluded, }, diff --git a/code/core/src/manager/components/sidebar/Sidebar.stories.tsx b/code/core/src/manager/components/sidebar/Sidebar.stories.tsx index db771c99c9da..bf4e3f4db5b1 100644 --- a/code/core/src/manager/components/sidebar/Sidebar.stories.tsx +++ b/code/core/src/manager/components/sidebar/Sidebar.stories.tsx @@ -51,8 +51,6 @@ const managerContext: any = { getShortcutKeys: fn(() => ({ search: ['control', 'shift', 's'] })).mockName( 'api::getShortcutKeys' ), - getIncludedTagFilters: fn(() => []).mockName('api::getIncludedTagFilters'), - getExcludedTagFilters: fn(() => []).mockName('api::getExcludedTagFilters'), getChannel: fn().mockName('api::getChannel'), getElements: fn(() => ({})), navigate: fn().mockName('api::navigate'), diff --git a/code/core/src/manager/components/sidebar/Sidebar.tsx b/code/core/src/manager/components/sidebar/Sidebar.tsx index 1ac66dd6b58c..16f659483dc5 100644 --- a/code/core/src/manager/components/sidebar/Sidebar.tsx +++ b/code/core/src/manager/components/sidebar/Sidebar.tsx @@ -184,7 +184,7 @@ export const Sidebar = React.memo(function Sidebar({ ) } - searchFieldContent={indexJson && } + searchFieldContent={indexJson && } {...lastViewedProps} > {({ diff --git a/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx b/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx index 595136f5ef9b..e01d63cdab79 100644 --- a/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx @@ -1,40 +1,148 @@ -import type { DocsIndexEntry, StoryIndexEntry } from 'storybook/internal/types'; +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 type { API } from 'storybook/manager-api'; +import { ManagerContext } from 'storybook/manager-api'; import { expect, screen, waitFor } from 'storybook/test'; +import { Close } from '../../../components/components/Modal/Modal.styled'; import { TagsFilter } from './TagsFilter'; -import { MockAPIDecorator } from './TagsFilter.story-helpers'; + +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'], - decorators: [MockAPIDecorator], - args: { - api: {} as API, // Will be overridden by MockAPIWrapper - indexJson: { - 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, - }, + 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 ClosedWithDefaultTags: Story = { + ...Closed, beforeEach: () => { const originalTagsOptions = global.TAGS_OPTIONS; global.TAGS_OPTIONS = { @@ -51,6 +159,14 @@ export const ClosedWithDefaultTags: Story = { 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'], }, }, @@ -74,6 +190,14 @@ 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'], }, }, @@ -91,18 +215,26 @@ export const ResetToDefaults: Story = { } satisfies Story; export const NoUserTags = { - ...Clear, - args: { - ...Clear.args, - indexJson: { - 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, - }, + 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 = { @@ -114,6 +246,14 @@ export const WithSelectionInverted = { ...Clear, 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'], }, }, @@ -123,6 +263,14 @@ export const WithSelectionMixed = { ...Clear, 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'], }, @@ -130,10 +278,12 @@ export const WithSelectionMixed = { } satisfies Story; export const Empty: Story = { - args: { - indexJson: { - v: 6, - entries: {}, + parameters: { + initialStoryState: { + internal_index: { + v: 6, + entries: {}, + } as StoryIndex, }, }, play: async ({ canvas }) => { @@ -147,8 +297,13 @@ export const Empty: Story = { /** Production is equal to development now */ export const EmptyProduction: Story = { - args: { - ...Empty.args, + parameters: { + initialStoryState: { + internal_index: { + v: 6, + entries: {}, + } as StoryIndex, + }, }, 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 index c670e35e5f22..a266f772d79c 100644 --- a/code/core/src/manager/components/sidebar/TagsFilter.story-helpers.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilter.story-helpers.tsx @@ -97,7 +97,8 @@ export class MockAPIWrapper extends React.Component<{ const { children, args } = this.props; return ( <> - {React.cloneElement(children as React.ReactElement, { + x{children} + {/* {React.cloneElement(children as React.ReactElement, { args: { ...args, api: { @@ -112,7 +113,7 @@ export class MockAPIWrapper extends React.Component<{ applyQueryParams: fn().mockName('api::applyQueryParams'), }, }, - })} + })} */} ); } diff --git a/code/core/src/manager/components/sidebar/TagsFilter.tsx b/code/core/src/manager/components/sidebar/TagsFilter.tsx index 7410fd1f5169..fa554eff6c25 100644 --- a/code/core/src/manager/components/sidebar/TagsFilter.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilter.tsx @@ -5,7 +5,7 @@ import type { StoryIndex } from 'storybook/internal/types'; import { FilterIcon } from '@storybook/icons'; -import type { API } from 'storybook/manager-api'; +import { type API, type Combo, Consumer } from 'storybook/manager-api'; import { styled } from 'storybook/theming'; import { TagsFilterPanel } from './TagsFilterPanel'; @@ -38,24 +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; + activeFilterCount: number; + defaultIncludedFilters: string[]; + defaultExcludedFilters: string[]; + includedFilters: string[]; + excludedFilters: string[]; } -export const TagsFilter = ({ api, indexJson }: TagsFilterProps) => { - const includedFilters = api.getIncludedTagFilters(); - const excludedFilters = api.getExcludedTagFilters(); - +const TagsFilterInner = ({ + api, + indexJson, + activeFilterCount, + defaultIncludedFilters, + defaultExcludedFilters, + includedFilters, + excludedFilters, +}: TagsFilterInnerProps) => { const [expanded, setExpanded] = useState(false); - const activeFilterCount = includedFilters.length + excludedFilters.length; const handleToggleExpand = useCallback( (event: React.SyntheticEvent): void => { event.preventDefault(); setExpanded(!expanded); }, - [expanded, setExpanded] + [expanded] ); return ( @@ -65,7 +85,16 @@ export const TagsFilter = ({ api, indexJson }: TagsFilterProps) => { onVisibleChange={setExpanded} offset={8} padding={0} - popover={() => } + popover={() => ( + + )} > { ); }; + +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 07d8ec3dc5a2..a12cefeb27e2 100644 --- a/code/core/src/manager/components/sidebar/TagsFilterPanel.stories.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilterPanel.stories.tsx @@ -1,10 +1,13 @@ -import type { DocsIndexEntry, StoryIndexEntry } from 'storybook/internal/types'; +import React 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 type { API } from 'storybook/manager-api'; +import { type API, type Combo, Consumer, ManagerContext } from 'storybook/manager-api'; +import { fn } from 'storybook/test'; import { MockAPIDecorator } from './TagsFilter.story-helpers'; import { TagsFilterPanel } from './TagsFilterPanel'; @@ -89,15 +92,20 @@ const getEntries = (includeUserTags: boolean) => { const meta = { component: TagsFilterPanel, title: 'Sidebar/TagsFilterPanel', + // Will provide api mock decorators: [MockAPIDecorator], + tags: ['hoho'], args: { - api: {} as API, // Will be overridden by MockAPIWrapper + api: {} as API, indexJson: { v: 6, entries: getEntries(true), - }, + } as StoryIndex, + defaultExcludedFilters: [], + defaultIncludedFilters: [], + includedFilters: [], + excludedFilters: [], }, - tags: ['hoho'], } satisfies Meta; export default meta; @@ -111,7 +119,7 @@ export const BuiltInOnly: Story = { indexJson: { v: 6, entries: getEntries(false), - }, + } as StoryIndex, }, }; @@ -122,64 +130,65 @@ export const BuiltInOnly: Story = { */ export const BuiltInOnlyProduction: Story = { args: { - ...BuiltInOnly.args, + indexJson: { + v: 6, + entries: getEntries(false), + } as StoryIndex, }, }; export const Included: Story = { - parameters: { - initialStoryState: { - includedTagFilters: ['tag1'], - }, + args: { + indexJson: { + v: 6, + entries: getEntries(true), + } as StoryIndex, + includedFilters: ['tag1'], }, }; export const Excluded: Story = { - parameters: { - initialStoryState: { - excludedTagFilters: ['tag1'], - }, + args: { + indexJson: { + v: 6, + entries: getEntries(true), + } as StoryIndex, + excludedFilters: ['tag1'], }, }; export const Mixed: Story = { - parameters: { - initialStoryState: { - includedTagFilters: ['tag1'], - excludedTagFilters: ['tag2'], - }, + args: { + indexJson: { + v: 6, + entries: getEntries(true), + } as StoryIndex, + includedFilters: ['tag1'], + excludedFilters: ['tag2'], }, }; export const DefaultSelection: Story = { - beforeEach: () => { - const originalTagsOptions = global.TAGS_OPTIONS; - global.TAGS_OPTIONS = { - tag1: { defaultFilterSelection: 'include' }, - tag2: { defaultFilterSelection: 'exclude' }, - }; - - return () => { - global.TAGS_OPTIONS = originalTagsOptions; - }; + args: { + indexJson: { + v: 6, + entries: getEntries(true), + } as StoryIndex, + includedFilters: ['tag1'], + excludedFilters: ['tag2'], + defaultIncludedFilters: ['tag1'], + defaultExcludedFilters: ['tag2'], }, }; export const DefaultSelectionModified: Story = { - beforeEach: () => { - const originalTagsOptions = global.TAGS_OPTIONS; - global.TAGS_OPTIONS = { - tag1: { defaultFilterSelection: 'include' }, - tag2: { defaultFilterSelection: 'exclude' }, - }; - - return () => { - global.TAGS_OPTIONS = originalTagsOptions; - }; - }, - parameters: { - initialStoryState: { - includedTagFilters: ['tag1', 'tag2'], - }, + args: { + 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 fbd95a652b18..cf172dd78a40 100644 --- a/code/core/src/manager/components/sidebar/TagsFilterPanel.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilterPanel.tsx @@ -18,6 +18,7 @@ import type { API } from 'storybook/manager-api'; 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'; type Filter = { id: string; @@ -26,7 +27,7 @@ type Filter = { count: number; }; -export const groupByType = (filters: Filter[]) => +const groupByType = (filters: Filter[]) => filters.filter(Boolean).reduce( (acc, filter) => { acc[filter.type] ??= []; @@ -52,6 +53,10 @@ const MutedText = styled.span(({ theme }) => ({ interface TagsFilterPanelProps { api: API; indexJson: StoryIndex; + defaultIncludedFilters: string[]; + defaultExcludedFilters: string[]; + includedFilters: string[]; + excludedFilters: string[]; } const BUILT_IN_TAGS = new Set([ @@ -69,13 +74,23 @@ const BUILT_IN_TAGS = new Set([ const equal = (left: string[], right: string[]) => left.length === right.length && new Set([...left, ...right]).size === left.length; -export const TagsFilterPanel = ({ api, indexJson }: TagsFilterPanelProps) => { - const ref = useRef(null); +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); + } +}; - const defaultIncluded = api.getDefaultIncludedTagFilters(); - const defaultExcluded = api.getDefaultExcludedTagFilters(); - const includedFilters = api.getIncludedTagFilters(); - const excludedFilters = api.getExcludedTagFilters(); +export const TagsFilterPanel = ({ + api, + indexJson, + defaultIncludedFilters, + defaultExcludedFilters, + includedFilters, + excludedFilters, +}: TagsFilterPanelProps) => { + const ref = useRef(null); const filtersById = useMemo<{ [id: string]: Filter }>(() => { const userTagsCounts = Object.values(indexJson.entries).reduce<{ [key: Tag]: number }>( @@ -105,26 +120,26 @@ export const TagsFilterPanel = ({ api, indexJson }: TagsFilterPanelProps) => { type: 'built-in', title: 'Documentation', icon: , - count: getBuiltInCount(api.getFilterFunction('_docs')), + count: getBuiltInCount(getFilterFunction('_docs')), }, _play: { id: '_play', type: 'built-in', title: 'Play', icon: , - count: getBuiltInCount(api.getFilterFunction('_play')), + count: getBuiltInCount(getFilterFunction('_play')), }, _test: { id: '_test', type: 'built-in', title: 'Testing', icon: , - count: getBuiltInCount(api.getFilterFunction('_test')), + count: getBuiltInCount(getFilterFunction('_test')), }, }; return { ...userFilters, ...builtInFilters }; - }, [api, indexJson.entries]); + }, [indexJson.entries]); const toggleFilter = useCallback( (id: string, selected: boolean, excluded?: boolean) => { @@ -147,12 +162,15 @@ export const TagsFilterPanel = ({ api, indexJson }: TagsFilterPanelProps) => { ); const isDefaultSelection = useMemo(() => { - return equal(includedFilters, defaultIncluded) && equal(excludedFilters, defaultExcluded); - }, [includedFilters, excludedFilters, defaultIncluded, defaultExcluded]); + return ( + equal(includedFilters, defaultIncludedFilters) && + equal(excludedFilters, defaultExcludedFilters) + ); + }, [includedFilters, excludedFilters, defaultIncludedFilters, defaultExcludedFilters]); const hasDefaultSelection = useMemo(() => { - return defaultIncluded.length > 0 || defaultExcluded.length > 0; - }, [defaultIncluded, defaultExcluded]); + return defaultIncludedFilters.length > 0 || defaultExcludedFilters.length > 0; + }, [defaultIncludedFilters, defaultExcludedFilters]); const builtInFilterIcons = useMemo( () => ({ 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); From d019e283c34729fbc754bd1110ad0852e74f4e86 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Thu, 12 Mar 2026 15:52:39 +0100 Subject: [PATCH 12/18] Fix regression in TagsFilterPanel story mock --- .../src/manager/components/sidebar/TagsFilter.stories.tsx | 1 - .../components/sidebar/TagsFilter.story-helpers.tsx | 5 ++--- .../manager/components/sidebar/TagsFilterPanel.stories.tsx | 7 +------ 3 files changed, 3 insertions(+), 10 deletions(-) diff --git a/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx b/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx index e01d63cdab79..988537f42691 100644 --- a/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx @@ -9,7 +9,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { ManagerContext } from 'storybook/manager-api'; import { expect, screen, waitFor } from 'storybook/test'; -import { Close } from '../../../components/components/Modal/Modal.styled'; import { TagsFilter } from './TagsFilter'; const getDefaultTagFilters = () => { diff --git a/code/core/src/manager/components/sidebar/TagsFilter.story-helpers.tsx b/code/core/src/manager/components/sidebar/TagsFilter.story-helpers.tsx index a266f772d79c..c670e35e5f22 100644 --- a/code/core/src/manager/components/sidebar/TagsFilter.story-helpers.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilter.story-helpers.tsx @@ -97,8 +97,7 @@ export class MockAPIWrapper extends React.Component<{ const { children, args } = this.props; return ( <> - x{children} - {/* {React.cloneElement(children as React.ReactElement, { + {React.cloneElement(children as React.ReactElement, { args: { ...args, api: { @@ -113,7 +112,7 @@ export class MockAPIWrapper extends React.Component<{ applyQueryParams: fn().mockName('api::applyQueryParams'), }, }, - })} */} + })} ); } diff --git a/code/core/src/manager/components/sidebar/TagsFilterPanel.stories.tsx b/code/core/src/manager/components/sidebar/TagsFilterPanel.stories.tsx index a12cefeb27e2..5640a94d1dee 100644 --- a/code/core/src/manager/components/sidebar/TagsFilterPanel.stories.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilterPanel.stories.tsx @@ -1,13 +1,8 @@ -import React 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 { type API, type Combo, Consumer, ManagerContext } from 'storybook/manager-api'; -import { fn } from 'storybook/test'; +import { type API } from 'storybook/manager-api'; import { MockAPIDecorator } from './TagsFilter.story-helpers'; import { TagsFilterPanel } from './TagsFilterPanel'; From e538742340f45bbee2f766683efb4a00f6a49940 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Thu, 12 Mar 2026 16:42:46 +0100 Subject: [PATCH 13/18] Tidy up manager context in story code and redundant index check --- .../manager/components/sidebar/Sidebar.stories.tsx | 13 +++++++------ .../src/manager/components/sidebar/Sidebar.tsx | 2 +- .../components/sidebar/TagsFilter.stories.tsx | 14 ++++++++++++-- 3 files changed, 20 insertions(+), 9 deletions(-) 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 }) => ( + ) } - 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 988537f42691..35d51c4faf00 100644 --- a/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx @@ -238,7 +238,10 @@ export const NoUserTags = { 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 = { @@ -256,10 +259,13 @@ export const WithSelectionInverted = { excludedTagFilters: ['A', 'B'], }, }, + play: async ({ canvas }) => { + const button = await canvas.findByRole('button', {}, { timeout: 3000 }); + button.click(); + }, } satisfies Story; export const WithSelectionMixed = { - ...Clear, parameters: { initialStoryState: { internal_index: { @@ -274,6 +280,10 @@ export const WithSelectionMixed = { excludedTagFilters: ['B'], }, }, + play: async ({ canvas }) => { + const button = await canvas.findByRole('button', {}, { timeout: 3000 }); + button.click(); + }, } satisfies Story; export const Empty: Story = { From 15677b758c53b32528d55ae72561994b1de26d82 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Thu, 12 Mar 2026 17:22:18 +0100 Subject: [PATCH 14/18] Fix playwright config url AGAIN :) --- code/playwright.config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/code/playwright.config.ts b/code/playwright.config.ts index 70c4d9d3feb1..af6f071f3c5c 100644 --- a/code/playwright.config.ts +++ b/code/playwright.config.ts @@ -4,8 +4,8 @@ import { defineConfig, devices } from '@playwright/test'; // require('dotenv').config(); // Comment this out and fill in the values to run E2E tests locally using the Playwright extension easily -process.env.STORYBOOK_URL = 'http://localhost:6006'; -process.env.STORYBOOK_TEMPLATE_NAME = 'react-vite/default-ts'; +// process.env.STORYBOOK_URL = 'http://localhost:6006'; +// process.env.STORYBOOK_TEMPLATE_NAME = 'react-vite/default-ts'; /** See https://playwright.dev/docs/test-configuration. */ export default defineConfig({ From ce5cd19260a32bb57ed57faa28d17c3f36085e3d Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Thu, 12 Mar 2026 17:32:16 +0100 Subject: [PATCH 15/18] Fix prioritisation issue in active filter count --- code/core/src/manager/components/sidebar/TagsFilter.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/core/src/manager/components/sidebar/TagsFilter.tsx b/code/core/src/manager/components/sidebar/TagsFilter.tsx index fa554eff6c25..86a56a624cd8 100644 --- a/code/core/src/manager/components/sidebar/TagsFilter.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilter.tsx @@ -42,7 +42,7 @@ const tagsFilterMapper = ({ api, state }: Combo) => ({ api, indexJson: state.internal_index as StoryIndex | undefined, activeFilterCount: - state.includedTagFilters?.length ?? 0 + (state.excludedTagFilters?.length ?? 0), + (state.includedTagFilters?.length ?? 0) + (state.excludedTagFilters?.length ?? 0), defaultIncludedFilters: state.defaultIncludedTagFilters, defaultExcludedFilters: state.defaultExcludedTagFilters, includedFilters: state.includedTagFilters, From 2524caefdb2c8b35f579db249acb775d049483c8 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Thu, 12 Mar 2026 18:00:19 +0100 Subject: [PATCH 16/18] Fix stories module unit test --- code/core/src/manager-api/tests/stories.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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({}), }) ); }); From 6e526551476d8bdf65c77a8f40ef33bc1d632160 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Fri, 13 Mar 2026 14:05:07 +0100 Subject: [PATCH 17/18] Try to help CI tsc not get stuck on false positives --- .../src/manager/components/sidebar/TagsFilter.story-helpers.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/code/core/src/manager/components/sidebar/TagsFilter.story-helpers.tsx b/code/core/src/manager/components/sidebar/TagsFilter.story-helpers.tsx index c670e35e5f22..8728a5043c87 100644 --- a/code/core/src/manager/components/sidebar/TagsFilter.story-helpers.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilter.story-helpers.tsx @@ -46,6 +46,7 @@ export class MockAPIWrapper extends React.Component<{ const provider: API_Provider = { getConfig: () => ({}), handleAPI: () => {}, + // @ts-no-check - TSC in CI fails to recognise this is the right Channel type. channel: this.channel, }; From 99654b4b59ecc494597aed66110ffc65be06229d Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Fri, 13 Mar 2026 16:03:49 +0100 Subject: [PATCH 18/18] Work around prod-only tsc limitation --- .../manager/components/sidebar/TagsFilter.story-helpers.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/code/core/src/manager/components/sidebar/TagsFilter.story-helpers.tsx b/code/core/src/manager/components/sidebar/TagsFilter.story-helpers.tsx index 8728a5043c87..516baebd8c0a 100644 --- a/code/core/src/manager/components/sidebar/TagsFilter.story-helpers.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilter.story-helpers.tsx @@ -46,7 +46,8 @@ export class MockAPIWrapper extends React.Component<{ const provider: API_Provider = { getConfig: () => ({}), handleAPI: () => {}, - // @ts-no-check - TSC in CI fails to recognise this is the right Channel type. + // 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, };