Skip to content
Draft
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
17 changes: 17 additions & 0 deletions code/core/src/manager-errors.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { title } from './core-server/presets/common-preset.ts';
import type { Status, StatusTypeId } from './shared/status-store/index.ts';
import { StorybookError } from './storybook-error.ts';

Expand Down Expand Up @@ -31,6 +32,22 @@ export class ProviderDoesNotExtendBaseProviderError extends StorybookError {
}
}

export class FilterError extends StorybookError {
constructor(
public data: {
title: string;
type: string;
}
) {
super({
name: 'FilterError',
category: Category.MANAGER_UI,
code: 1,
message: `Unknown filter type '${data.type}' with value '${data.title}'`,
});
}
}

export class UncaughtManagerError extends StorybookError {
constructor(
public data: {
Expand Down
46 changes: 46 additions & 0 deletions code/core/src/manager/components/sidebar/FilterPanel.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
StatusValue,
} from 'storybook/internal/types';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, userEvent } from 'storybook/test';
import type { API } from '../../../manager-api/index.ts';

import { IconSymbolsDecorator, MockAPIDecorator } from './Filter.story-helpers.tsx';
Expand Down Expand Up @@ -299,3 +300,48 @@ export const OnlyAffectedStatus: Story = {
}),
},
};

const projectedCounterEntries = {
'foo-1': { tags: ['foo', 'ter'], type: 'story' } as StoryIndexEntry,
'foo-2': { tags: ['foo', 'ter'], type: 'story' } as StoryIndexEntry,
'foo-3': { tags: ['foo', 'ter'], type: 'story' } as StoryIndexEntry,
'foo-4': { tags: ['foo', 'ter'], type: 'story' } as StoryIndexEntry,
'foo-5': { tags: ['foo', 'ter'], type: 'story' } as StoryIndexEntry,
'bar-1': { tags: ['bar'], type: 'story' } as StoryIndexEntry,
'bar-2': { tags: ['bar'], type: 'story' } as StoryIndexEntry,
'bar-3': { tags: ['bar'], type: 'story' } as StoryIndexEntry,
'bar-4': { tags: ['bar'], type: 'story' } as StoryIndexEntry,
'bar-5': { tags: ['bar'], type: 'story' } as StoryIndexEntry,
'ter-1': { tags: ['ter'], type: 'story' } as StoryIndexEntry,
'ter-2': { tags: ['ter'], type: 'story' } as StoryIndexEntry,
'ter-3': { tags: ['ter'], type: 'story' } as StoryIndexEntry,
'ter-4': { tags: ['ter'], type: 'story' } as StoryIndexEntry,
'ter-5': { tags: ['ter'], type: 'story' } as StoryIndexEntry,
'ter-6': { tags: ['ter'], type: 'story' } as StoryIndexEntry,
'ter-7': { tags: ['ter'], type: 'story' } as StoryIndexEntry,
'ter-8': { tags: ['ter'], type: 'story' } as StoryIndexEntry,
'ter-9': { tags: ['ter'], type: 'story' } as StoryIndexEntry,
'ter-10': { tags: ['ter'], type: 'story' } as StoryIndexEntry,
};

export const ProjectedTagCounts: Story = {
args: {
indexJson: {
v: 6,
entries: projectedCounterEntries,
} as StoryIndex,
includedFilters: ['foo', 'bar'],
},
play: async ({ canvas }) => {
const terCheckbox = await canvas.findByRole('checkbox', {
name: /include tag filter ter\. 10 items would be added\./i,
});

await userEvent.hover(terCheckbox);

await expect(
canvas.getByLabelText('20 of 20 items visible if you include tag filter ter')
).toBeVisible();
await expect(canvas.getByLabelText('10 items would be added')).toBeVisible();
},
};
179 changes: 147 additions & 32 deletions code/core/src/manager/components/sidebar/FilterPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,29 @@
import React, { Fragment, useCallback, useMemo } from 'react';
import React, { Fragment, useCallback, useMemo, useState } from 'react';

import { ActionList } from 'storybook/internal/components';
import type { StatusValue, StatusesByStoryIdAndTypeId, StoryIndex } from 'storybook/internal/types';

import { BatchAcceptIcon, DocumentIcon, ShareAltIcon, SweepIcon, UndoIcon } from '@storybook/icons';

import type { API } from 'storybook/manager-api';
import { styled, useTheme } from 'storybook/theming';
import { styled, useTheme, type Theme } from 'storybook/theming';

import { getStatus } from '../../utils/status.tsx';
import { createFilterLink, StatusIcon } from './FilterPanelLink.tsx';
import { type FilterItem, areFiltersEqual } from './FilterPanel.utils.ts';
import {
type FilterItem,
type FilterPreviewAction,
areFiltersEqual,
computeFilterPanelCounts,
getFilterPreviewDescription,
} from './FilterPanel.utils.ts';
import {
type StatusFilterEntry,
type TagFilterEntry,
useStatusFilterEntries,
useTagFilterEntries,
} from './useFilterData.tsx';
import { transparentize } from 'polished';

const Wrapper = styled.div({
minWidth: 240,
Expand All @@ -27,6 +34,57 @@ const Wrapper = styled.div({
scrollbarWidth: 'thin',
});

const StickyActionList = styled(ActionList)(({ theme }) => ({
position: 'sticky',
top: 0,
zIndex: 1,
background: theme.base === 'light' ? theme.background.content : theme.background.app,
borderBottom: `1px solid ${theme.appBorderColor}`,
'&& + *': {
borderTop: `none`,
},
}));

const SummaryRow = styled.div<{ $delta: number }>(({ theme, $delta }) => ({
display: 'flex',
alignItems: 'center',
gap: 4,
marginInlineStart: 'auto',
fontSize: theme.typography.size.s1,
whiteSpace: 'nowrap',
borderRadius: 999,
padding: '2px 8px',
fontVariantNumeric: 'tabular-nums',
// background: getSummaryCountBackgroundColor($delta, theme),
color: getSummaryCountColor($delta, theme),
}));

const getSummaryCountBackgroundColor = (delta: number, theme: Theme) => {
if (delta > 0) {
return theme.background.positive;
}

if (delta < 0) {
return theme.background.negative;
}

return 'inherit';
};

const getSummaryCountColor = (delta: number, theme: Theme) => {
if (delta > 0) {
return theme.color.positiveText;
}

if (delta < 0) {
return theme.color.negativeText;
}
return theme.textMutedColor;
};

const getItemPreviewId = (item: Pick<FilterItem, 'id' | 'type'>) =>
`filter-${item.type}-${item.id}`;

export interface FilterPanelProps {
api: API;
indexJson: StoryIndex;
Expand All @@ -51,10 +109,42 @@ export const FilterPanel = ({
excludedStatusFilters,
}: FilterPanelProps) => {
const theme = useTheme();
const [previewState, setPreviewState] = useState<{
action: FilterPreviewAction;
itemId: string;
} | null>(null);

const { builtInEntries, tagEntries } = useTagFilterEntries(indexJson);
const statusEntries = useStatusFilterEntries(allStatuses);

const filterCounts = useMemo(
() =>
computeFilterPanelCounts({
allStatuses,
includedFilters,
excludedFilters,
includedStatusFilters,
excludedStatusFilters,
indexJson,
statusValues: statusEntries.map((entry) => entry.statusValue),
tagIds: [
...builtInEntries.map((entry) => entry.id),
...tagEntries.map((entry) => entry.id),
],
}),
[
allStatuses,
builtInEntries,
excludedFilters,
excludedStatusFilters,
includedFilters,
includedStatusFilters,
indexJson,
statusEntries,
tagEntries,
]
);

const toTagFilterItem = useCallback(
(entry: TagFilterEntry): FilterItem | null => {
if (entry.count === 0 && entry.type === 'built-in') return null;
Expand All @@ -66,6 +156,9 @@ export const FilterPanel = ({
type: entry.type,
title: entry.title,
count: entry.count,
visibleCount: filterCounts.tags[entry.id]?.visibleCount ?? 0,
toggle: filterCounts.tags[entry.id]?.toggle ?? { delta: 0, visibleCount: 0 },
invert: filterCounts.tags[entry.id]?.invert ?? { delta: 0, visibleCount: 0 },
icon: entry.icon,
isIncluded,
isExcluded,
Expand All @@ -79,7 +172,7 @@ export const FilterPanel = ({
onInvert: () => api.addTagFilters([entry.id], !isExcluded),
};
},
[api, includedFilters, excludedFilters]
[api, excludedFilters, filterCounts.tags, includedFilters]
);

const toStatusFilterItem = useCallback(
Expand All @@ -93,6 +186,9 @@ export const FilterPanel = ({
type: 'status',
title: entry.shortName.charAt(0).toUpperCase() + entry.shortName.slice(1),
count: entry.count,
visibleCount: filterCounts.statuses[entry.statusValue]?.visibleCount ?? 0,
toggle: filterCounts.statuses[entry.statusValue]?.toggle ?? { delta: 0, visibleCount: 0 },
invert: filterCounts.statuses[entry.statusValue]?.invert ?? { delta: 0, visibleCount: 0 },
icon: statusIconEl ? <StatusIcon $iconColor={iconColor}>{statusIconEl}</StatusIcon> : null,
isIncluded,
isExcluded,
Expand All @@ -106,7 +202,7 @@ export const FilterPanel = ({
onInvert: () => api.addStatusFilters([entry.statusValue], !isExcluded),
};
},
[api, includedStatusFilters, excludedStatusFilters, theme]
[api, excludedStatusFilters, filterCounts.statuses, includedStatusFilters, theme]
);

const builtInItems = useMemo(
Expand Down Expand Up @@ -157,12 +253,49 @@ export const FilterPanel = ({
includedStatusFilters.length === 0 &&
excludedStatusFilters.length === 0;

const hasItems = builtInItems.length > 0 || tagItems.length > 0;
const hasItems = builtInItems.length > 0 || tagItems.length > 0 || statusItems.length > 0;
const allItems = useMemo(
() => [...builtInItems, ...statusItems, ...tagItems],
[builtInItems, statusItems, tagItems]
);
const previewAction = previewState?.action ?? null;
const previewedItem = previewState
? allItems.find((item) => getItemPreviewId(item) === previewState.itemId)
: null;
const previewedProjection = previewedItem && previewAction ? previewedItem[previewAction] : null;
const summaryCount = previewedProjection?.visibleCount ?? filterCounts.currentVisibleCount;
const summaryCountString = `${summaryCount}/${filterCounts.totalCount}`;
const summaryAriaLabel =
previewedItem && previewedProjection && previewAction
? `${summaryCount} of ${filterCounts.totalCount} items visible if you ${getFilterPreviewDescription(
previewedItem,
previewAction
)}`
: `${summaryCount} of ${filterCounts.totalCount} items currently visible`;
const summaryDelta = previewedProjection?.delta ?? 0;

const renderItem = useCallback(
(item: FilterItem) => {
const itemPreviewId = getItemPreviewId(item);
const link = createFilterLink(item, {
activePreviewAction: previewState?.itemId === itemPreviewId ? previewState.action : null,
onPreviewEnd: () => {
setPreviewState((current) => (current?.itemId === itemPreviewId ? null : current));
},
onPreviewStart: (action) => {
setPreviewState({ action, itemId: itemPreviewId });
},
});

return <Fragment key={link.id}>{link.content}</Fragment>;
},
[previewState]
);

return (
<Wrapper>
{hasItems && (
<ActionList as="div">
<StickyActionList as="div">
<ActionList.Item as="div">
{isNothingSelectedYet ? (
<ActionList.Button
Expand Down Expand Up @@ -200,33 +333,15 @@ export const FilterPanel = ({
<UndoIcon />
</ActionList.Button>
)}
<SummaryRow aria-label={summaryAriaLabel} $delta={summaryDelta}>
<span aria-hidden>{summaryCountString}</span>
</SummaryRow>
</ActionList.Item>
</ActionList>
)}
{builtInItems.length > 0 && (
<ActionList>
{builtInItems.map((item) => {
const link = createFilterLink(item);
return <Fragment key={link.id}>{link.content}</Fragment>;
})}
</ActionList>
)}
{statusItems.length > 0 && (
<ActionList>
{statusItems.map((item) => {
const link = createFilterLink(item);
return <Fragment key={link.id}>{link.content}</Fragment>;
})}
</ActionList>
)}
{tagItems.length > 0 && (
<ActionList>
{tagItems.map((item) => {
const link = createFilterLink(item);
return <Fragment key={link.id}>{link.content}</Fragment>;
})}
</ActionList>
</StickyActionList>
)}
{builtInItems.length > 0 && <ActionList>{builtInItems.map(renderItem)}</ActionList>}
{statusItems.length > 0 && <ActionList>{statusItems.map(renderItem)}</ActionList>}
{tagItems.length > 0 && <ActionList>{tagItems.map(renderItem)}</ActionList>}
{tagItems.length === 0 && (
<ActionList as="div">
<ActionList.Item as="div">
Expand Down
Loading
Loading