Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 13 additions & 12 deletions code/core/src/manager/components/sidebar/TagsFilter.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,54 +50,55 @@ export const ClosedWithSelection: Story = {
},
};

export const Open = {
export const Clear = {
...Closed,
play: async ({ canvasElement }) => {
const button = await findByRole(canvasElement, 'button');
await button.click();
},
} 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' },
},
},
} satisfies Story;

export const OpenWithSelectionMixed = {
...Open,
export const WithSelectionMixed = {
...Clear,
args: {
...Open.args,
...Clear.args,
tagPresets: {
A: { defaultFilterSelection: 'include' },
B: { defaultFilterSelection: 'exclude' },
},
},
} 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,
};
168 changes: 119 additions & 49 deletions code/core/src/manager/components/sidebar/TagsFilter.tsx
Original file line number Diff line number Diff line change
@@ -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',
});
Expand Down Expand Up @@ -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<Tag, number>());

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: <DocumentIcon color={color.gold} />,
...withCount((entry: API_PreparedIndexEntry, excluded?: boolean) =>
excluded ? entry.type !== 'docs' : entry.type === 'docs'
),
},
_play: {
id: '_play',
type: 'built-in',
title: 'Play',
icon: <PlayHollowIcon color={color.seafoam} />,
...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: <BeakerIcon color={color.green} />,
...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(() => {
Expand All @@ -63,64 +127,70 @@ export const TagsFilter = ({ api, indexJson, isDevelopment, tagPresets }: TagsFi
}
return acc;
},
{ defaultIncluded: new Set<Tag>(), defaultExcluded: new Set<Tag>() }
{ defaultIncluded: new Set<string>(), defaultExcluded: new Set<string>() }
);
}, [tagPresets]);

const [includedTags, setIncludedTags] = useState<Set<Tag>>(new Set(defaultIncluded));
const [excludedTags, setExcludedTags] = useState<Set<Tag>>(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(
Expand All @@ -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;
}

Expand All @@ -146,16 +216,16 @@ export const TagsFilter = ({ api, indexJson, isDevelopment, tagPresets }: TagsFi
tooltip={() => (
<TagsFilterPanel
api={api}
allTags={allTags}
includedTags={includedTags}
excludedTags={excludedTags}
toggleTag={toggleTag}
setAllTags={setAllTags}
resetTags={resetTags}
filtersById={filtersById}
includedFilters={includedFilters}
excludedFilters={excludedFilters}
toggleFilter={toggleFilter}
setAllFilters={setAllFilters}
resetFilters={resetFilters}
isDevelopment={isDevelopment}
isDefaultSelection={
includedTags.symmetricDifference(defaultIncluded).size === 0 &&
excludedTags.symmetricDifference(defaultExcluded).size === 0
includedFilters.symmetricDifference(defaultIncluded).size === 0 &&
excludedFilters.symmetricDifference(defaultExcluded).size === 0
}
hasDefaultSelection={defaultIncluded.size > 0 || defaultExcluded.size > 0}
/>
Expand All @@ -166,7 +236,7 @@ export const TagsFilter = ({ api, indexJson, isDevelopment, tagPresets }: TagsFi
<IconButton key="tags" title="Tag filters" active={tagsActive} onClick={handleToggleExpand}>
<FilterIcon />
</IconButton>
{includedTags.size + excludedTags.size > 0 && <TagSelected />}
{includedFilters.size + excludedFilters.size > 0 && <TagSelected />}
</Wrapper>
</WithTooltip>
);
Expand Down
Loading