diff --git a/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx b/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx index 8bc48ded4f45..8fa7ba7139ee 100644 --- a/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilter.stories.tsx @@ -50,7 +50,7 @@ export const ClosedWithSelection: Story = { }, }; -export const Open = { +export const Clear = { ...Closed, play: async ({ canvasElement }) => { const button = await findByRole(canvasElement, 'button'); @@ -58,15 +58,15 @@ export const Open = { }, } satisfies Story; -export const OpenWithSelection = { +export const WithSelection = { ...ClosedWithSelection, - play: Open.play, + play: Clear.play, } satisfies Story; -export const OpenWithSelectionInverted = { - ...Open, +export const WithSelectionInverted = { + ...Clear, args: { - ...Open.args, + ...Clear.args, tagPresets: { A: { defaultFilterSelection: 'exclude' }, B: { defaultFilterSelection: 'exclude' }, @@ -74,10 +74,10 @@ export const OpenWithSelectionInverted = { }, } satisfies Story; -export const OpenWithSelectionMixed = { - ...Open, +export const WithSelectionMixed = { + ...Clear, args: { - ...Open.args, + ...Clear.args, tagPresets: { A: { defaultFilterSelection: 'include' }, B: { defaultFilterSelection: 'exclude' }, @@ -85,19 +85,20 @@ export const OpenWithSelectionMixed = { }, } satisfies Story; -export const OpenEmpty: Story = { +export const Empty: Story = { args: { indexJson: { v: 6, entries: {}, }, }, - play: Open.play, + play: Clear.play, }; export const EmptyProduction: Story = { args: { - ...OpenEmpty.args, + ...Empty.args, isDevelopment: false, }, + play: Clear.play, }; diff --git a/code/core/src/manager/components/sidebar/TagsFilter.tsx b/code/core/src/manager/components/sidebar/TagsFilter.tsx index 5b2bacd38ec7..16694d57f876 100644 --- a/code/core/src/manager/components/sidebar/TagsFilter.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilter.tsx @@ -1,17 +1,32 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Badge, IconButton, WithTooltip } from 'storybook/internal/components'; -import type { StoryIndex, Tag, TagsOptions } from 'storybook/internal/types'; +import type { + API_PreparedIndexEntry, + StoryIndex, + Tag, + TagsOptions, +} from 'storybook/internal/types'; -import { FilterIcon } from '@storybook/icons'; +import { BeakerIcon, DocumentIcon, FilterIcon, PlayHollowIcon } from '@storybook/icons'; import type { API } from 'storybook/manager-api'; -import { styled } from 'storybook/theming'; +import { color, styled } from 'storybook/theming'; -import { HIDDEN_TAGS, TagsFilterPanel } from './TagsFilterPanel'; +import { type Filter, type FilterFunction, TagsFilterPanel, groupByType } from './TagsFilterPanel'; const TAGS_FILTER = 'tags-filter'; +const BUILT_IN_TAGS = new Set([ + 'dev', + 'test', + 'autodocs', + 'attached-mdx', + 'unattached-mdx', + 'play-fn', + 'test-fn', +]); + const Wrapper = styled.div({ position: 'relative', }); @@ -42,15 +57,64 @@ export interface TagsFilterProps { } export const TagsFilter = ({ api, indexJson, isDevelopment, tagPresets }: TagsFilterProps) => { - const allTags = useMemo(() => { - return Object.values(indexJson.entries).reduce((acc, entry) => { + const filtersById = useMemo<{ [id: string]: Filter }>(() => { + const userTagsCounts = Object.values(indexJson.entries).reduce((acc, entry) => { entry.tags?.forEach((tag: Tag) => { - if (!HIDDEN_TAGS.has(tag)) { + if (!BUILT_IN_TAGS.has(tag)) { acc.set(tag, (acc.get(tag) || 0) + 1); } }); return acc; }, new Map()); + + const userFilters = Object.fromEntries( + userTagsCounts.entries().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('play-fn') + : entry.type === 'story' && !!entry.tags?.includes('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(() => { @@ -63,64 +127,70 @@ export const TagsFilter = ({ api, indexJson, isDevelopment, tagPresets }: TagsFi } return acc; }, - { defaultIncluded: new Set(), defaultExcluded: new Set() } + { defaultIncluded: new Set(), defaultExcluded: new Set() } ); }, [tagPresets]); - const [includedTags, setIncludedTags] = useState>(new Set(defaultIncluded)); - const [excludedTags, setExcludedTags] = useState>(new Set(defaultExcluded)); + const [includedFilters, setIncludedFilters] = useState(new Set(defaultIncluded)); + const [excludedFilters, setExcludedFilters] = useState(new Set(defaultExcluded)); const [expanded, setExpanded] = useState(false); - const tagsActive = includedTags.size > 0 || excludedTags.size > 0; + const tagsActive = includedFilters.size > 0 || excludedFilters.size > 0; - const resetTags = useCallback(() => { - setIncludedTags(new Set(defaultIncluded)); - setExcludedTags(new Set(defaultExcluded)); + const resetFilters = useCallback(() => { + setIncludedFilters(new Set(defaultIncluded)); + setExcludedFilters(new Set(defaultExcluded)); }, [defaultIncluded, defaultExcluded]); - useEffect(resetTags, [resetTags]); + useEffect(resetFilters, [resetFilters]); useEffect(() => { api.experimental_setFilter(TAGS_FILTER, (item) => { - if (!includedTags.size && !excludedTags.size) { - return true; - } + 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 ( - (!includedTags.size || includedTags.values().some((tag) => item.tags?.includes(tag))) && - (!excludedTags.size || excludedTags.values().every((tag) => !item.tags?.includes(tag))) + (!included.length || + included.every((group) => group.some(({ filterFn }) => filterFn(item, false)))) && + (!excluded.length || + excluded.every((group) => group.every(({ filterFn }) => filterFn(item, true)))) ); }); - }, [api, includedTags, excludedTags]); + }, [api, includedFilters, excludedFilters, filtersById]); - const toggleTag = useCallback( - (tag: string, excluded?: boolean) => { - const set = new Set([tag]); + const toggleFilter = useCallback( + (id: string, selected: boolean, excluded?: boolean) => { + const set = new Set([id]); if (excluded === true) { - setExcludedTags(excludedTags.union(set)); - setIncludedTags(includedTags.difference(set)); + setExcludedFilters(excludedFilters.union(set)); + setIncludedFilters(includedFilters.difference(set)); } else if (excluded === false) { - setIncludedTags(includedTags.union(set)); - setExcludedTags(excludedTags.difference(set)); - } else if (includedTags.has(tag)) { - setIncludedTags(includedTags.difference(set)); - } else if (excludedTags.has(tag)) { - setExcludedTags(excludedTags.difference(set)); + setIncludedFilters(includedFilters.union(set)); + setExcludedFilters(excludedFilters.difference(set)); + } else if (selected) { + setIncludedFilters(includedFilters.union(set)); + setExcludedFilters(excludedFilters.difference(set)); } else { - setIncludedTags(includedTags.union(set)); + setIncludedFilters(includedFilters.difference(set)); + setExcludedFilters(excludedFilters.difference(set)); } }, - [includedTags, excludedTags] + [includedFilters, excludedFilters] ); - const setAllTags = useCallback( + const setAllFilters = useCallback( (selected: boolean) => { if (selected) { - setIncludedTags(new Set(allTags.keys())); + setIncludedFilters(new Set(Object.keys(filtersById))); } else { - setIncludedTags(new Set()); + setIncludedFilters(new Set()); } - setExcludedTags(new Set()); + setExcludedFilters(new Set()); }, - [allTags] + [filtersById] ); const handleToggleExpand = useCallback( @@ -132,7 +202,7 @@ export const TagsFilter = ({ api, indexJson, isDevelopment, tagPresets }: TagsFi ); // Hide the entire UI if there are no tags and it's a built Storybook - if (allTags.size === 0 && !isDevelopment) { + if (Object.keys(filtersById).length === 0 && !isDevelopment) { return null; } @@ -146,16 +216,16 @@ export const TagsFilter = ({ api, indexJson, isDevelopment, tagPresets }: TagsFi tooltip={() => ( 0 || defaultExcluded.size > 0} /> @@ -166,7 +236,7 @@ export const TagsFilter = ({ api, indexJson, isDevelopment, tagPresets }: TagsFi - {includedTags.size + excludedTags.size > 0 && } + {includedFilters.size + excludedFilters.size > 0 && } ); diff --git a/code/core/src/manager/components/sidebar/TagsFilterPanel.stories.tsx b/code/core/src/manager/components/sidebar/TagsFilterPanel.stories.tsx index fc7d7481bb64..ee4847fe22f2 100644 --- a/code/core/src/manager/components/sidebar/TagsFilterPanel.stories.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilterPanel.stories.tsx @@ -1,25 +1,72 @@ +import { BeakerIcon, DocumentIcon, PlayHollowIcon } from '@storybook/icons'; + import type { Meta, StoryObj } from '@storybook/react-vite'; import { fn } from 'storybook/test'; +import { color } from 'storybook/theming'; 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 meta = { component: TagsFilterPanel, title: 'Sidebar/TagsFilterPanel', args: { - toggleTag: fn(), - setAllTags: fn(), - allTags: new Map([ - ['play-fn', 1], - ['test-fn', 1], - ['tag1', 1], - ['tag2', 1], - ['tag3-which-is-very-long-and-will-be-truncated-after-a-while', 1], - ]), - includedTags: new Set(), - excludedTags: new Set(), - resetTags: fn(), + toggleFilter: fn(), + setAllFilters: fn(), + filtersById: { + tag1: { + id: 'tag1', + type: 'tag', + title: 'Tag1', + count: 11, + filterFn: fn(), + }, + tag2: { + id: 'tag2', + type: 'tag', + title: 'Tag2', + count: 24, + filterFn: fn(), + }, + 'tag3-which-is-very-long-and-will-be-truncated-after-a-while': { + id: 'tag3-which-is-very-long-and-will-be-truncated-after-a-while', + type: 'tag', + title: 'Tag3', + count: 2, + filterFn: fn(), + }, + ...builtInFilters, + }, + includedFilters: new Set(), + excludedFilters: new Set(), + resetFilters: fn(), isDefaultSelection: true, hasDefaultSelection: false, api: { @@ -36,46 +83,37 @@ type Story = StoryObj; export const Basic: Story = {}; -export const Empty: Story = { - args: { - allTags: new Map(), - }, -}; - -export const BuiltInTagsOnly: Story = { +export const BuiltInOnly: Story = { args: { - allTags: new Map([ - ['play-fn', 1], - ['test-fn', 1], - ]), + filtersById: builtInFilters, }, }; -export const BuiltInTagsOnlyProduction: Story = { +export const BuiltInOnlyProduction: Story = { args: { - ...BuiltInTagsOnly.args, + ...BuiltInOnly.args, isDevelopment: false, }, }; export const Included: Story = { args: { - includedTags: new Set(['tag1', 'play-fn']), + includedFilters: new Set(['tag1', '_play']), isDefaultSelection: false, }, }; export const Excluded: Story = { args: { - excludedTags: new Set(['tag1', 'play-fn']), + excludedFilters: new Set(['tag1', '_play']), isDefaultSelection: false, }, }; export const Mixed: Story = { args: { - includedTags: new Set(['tag1', 'play-fn']), - excludedTags: new Set(['tag2', 'test-fn']), + includedFilters: new Set(['tag1', '_play']), + excludedFilters: new Set(['tag2', '_test']), isDefaultSelection: false, }, }; diff --git a/code/core/src/manager/components/sidebar/TagsFilterPanel.tsx b/code/core/src/manager/components/sidebar/TagsFilterPanel.tsx index 3dc6789672d0..369480f4d250 100644 --- a/code/core/src/manager/components/sidebar/TagsFilterPanel.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilterPanel.tsx @@ -9,34 +9,31 @@ import { TooltipNote, WithTooltip, } from 'storybook/internal/components'; -import type { Tag } from 'storybook/internal/types'; +import type { API_PreparedIndexEntry } from 'storybook/internal/types'; import { BatchAcceptIcon, - BeakerIcon, DeleteIcon, DocumentIcon, - PlayHollowIcon, ShareAltIcon, SweepIcon, UndoIcon, } from '@storybook/icons'; import type { API } from 'storybook/manager-api'; -import { color, styled } from 'storybook/theming'; +import { styled } from 'storybook/theming'; import type { Link } from '../../../components/components/tooltip/TooltipLinkList'; -export const HIDDEN_TAGS = new Set(['dev', 'test']); - -export const BUILT_IN_TAGS = new Set([ - ...HIDDEN_TAGS, - 'autodocs', - 'attached-mdx', - 'unattached-mdx', - 'play-fn', - 'test-fn', -]); +export const groupByType = (filters: Filter[]) => + filters.reduce( + (acc, filter) => { + acc[filter.type] = acc[filter.type] || []; + acc[filter.type].push(filter); + return acc; + }, + {} as Record + ); const Wrapper = styled.div({ minWidth: 240, @@ -85,14 +82,23 @@ 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; - allTags: Map; - includedTags: Set; - excludedTags: Set; - toggleTag: (tag: Tag, excluded?: boolean) => void; - setAllTags: (selected: boolean) => void; - resetTags: () => void; + filtersById: { [id: string]: Filter }; + includedFilters: Set; + excludedFilters: Set; + toggleFilter: (key: string, selected: boolean, excluded?: boolean) => void; + setAllFilters: (selected: boolean) => void; + resetFilters: () => void; isDevelopment: boolean; isDefaultSelection: boolean; hasDefaultSelection: boolean; @@ -100,62 +106,47 @@ interface TagsFilterPanelProps { export const TagsFilterPanel = ({ api, - allTags, - includedTags, - excludedTags, - toggleTag, - setAllTags, - resetTags, + filtersById, + includedFilters, + excludedFilters, + toggleFilter, + setAllFilters, + resetFilters, isDevelopment, isDefaultSelection, hasDefaultSelection, }: TagsFilterPanelProps) => { const ref = useRef(null); - const userEntries = Array.from(allTags.entries()) - .filter(([tag]) => !BUILT_IN_TAGS.has(tag)) - .sort((a, b) => a[0].localeCompare(b[0])) - .map(([tag, count]) => ({ - title: tag, - count, - onToggle: (excluded?: boolean) => toggleTag(tag, excluded), - isIncluded: includedTags.has(tag), - isExcluded: excludedTags.has(tag), - })); - - const docsUrl = api.getDocsUrl({ subpath: 'writing-stories/tags#filtering-by-custom-tags' }); - - const noTags = { - id: 'no-tags', - title: 'There are no tags. Use tags to organize and filter your Storybook.', - isIndented: false, - }; - - const renderTag = ({ - icon, + const renderLink = ({ + id, + type, title, + icon, count, - onToggle, - isIncluded, - isExcluded, }: { - icon?: React.ReactNode; + id: string; + type: string; title: string; + icon?: React.ReactNode; count: number; - onToggle: (excluded?: boolean) => void; - isIncluded: boolean; - isExcluded: boolean; - }) => { + }): Link => { + const onToggle = (selected: boolean, excluded?: boolean) => + toggleFilter(id, selected, excluded); + const isIncluded = includedFilters.has(id); + const isExcluded = excludedFilters.has(id); const isChecked = isIncluded || isExcluded; + const toggleTagLabel = `${isChecked ? 'Remove' : 'Add'} ${type} filter: ${title}`; + const invertButtonLabel = `${isExcluded ? 'Include' : 'Exclude'} ${type}: ${title}`; return { - id: `tag-${title}`, + id: `filter-${type}-${id}`, content: ( } + tooltip={} trigger="hover" > {isExcluded ? : isIncluded ? null : icon} - onToggle()} data-tag={title} /> + onToggle(!isChecked)} + data-tag={title} + /> } + aria-label={toggleTagLabel} title={