Skip to content

Commit

Permalink
TagsFilter: Initial implementation and stories
Browse files Browse the repository at this point in the history
  • Loading branch information
shilman committed Jun 16, 2024
1 parent a5f8eb1 commit be48b2b
Show file tree
Hide file tree
Showing 5 changed files with 312 additions and 45 deletions.
16 changes: 15 additions & 1 deletion code/ui/manager/src/components/sidebar/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,21 @@ export const Sidebar = React.memo(function Sidebar({
isLoading={isLoading}
onMenuClick={onMenuClick}
/>
<TagsFilter api={api} />
<TagsFilter
api={api}
index={index}
updateQueryParams={(params) => {
const url = new URL(window.location.href);
Object.entries(params).forEach(([key, value]) => {
if (value) {
url.searchParams.set(key, value);
} else {
url.searchParams.delete(key);
}
});
window.history.pushState({}, '', url);
}}
/>
<Search
dataset={dataset}
enableShortcuts={enableShortcuts}
Expand Down
44 changes: 44 additions & 0 deletions code/ui/manager/src/components/sidebar/TagsFilter.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { Meta, StoryObj } from '@storybook/react';
import { findByRole, fn } from '@storybook/test';

import { TagsFilter } from './TagsFilter';

const meta = {
component: TagsFilter,
} satisfies Meta<typeof TagsFilter>;

export default meta;

type Story = StoryObj<typeof meta>;

export const Closed: Story = {
args: {
api: {
experimental_setFilter: fn(),
} as any,
index: {
story1: { type: 'story', tags: ['A', 'B', 'C', 'dev'] } as any,
},
updateQueryParams: fn(),
},
};

export const ClosedWithSelection: Story = {
args: {
...Closed.args,
initialSelectedTags: ['A', 'B'],
},
};

export const Open: Story = {
...Closed,
play: async ({ canvasElement }) => {
const button = await findByRole(canvasElement, 'button');
await button.click();
},
};

export const OpenWithSelection: Story = {
...ClosedWithSelection,
play: Open.play,
};
110 changes: 66 additions & 44 deletions code/ui/manager/src/components/sidebar/TagsFilter.tsx
Original file line number Diff line number Diff line change
@@ -1,60 +1,82 @@
import React, { useState, useEffect } from 'react';
import { IconButton, WithTooltip } from '@storybook/components';
import { FilterIcon } from '@storybook/icons';
import type { API } from '@storybook/manager-api';
import type { Tag, API_IndexHash } from '@storybook/types';
import { IconButton } from '@storybook/components';
import { FilterIcon } from '@storybook/icons';
import { TagsFilterPanel } from './TagsFilterPanel';

interface TagsFilterProps {
const TAGS_FILTER = 'tags-filter';

export interface TagsFilterProps {
api: API;
index: API_IndexHash;
updateQueryParams: (params: Record<string, string | null>) => void;
initialSelectedTags?: Tag[];
}

const UI_FILTER = 'ui-filter';

export const TagsFilter = ({ api }: TagsFilterProps) => {
const [includeTags, setIncludeTags] = useState([]);
const [excludeTags, setExcludeTags] = useState([]);
const tagsActive = includeTags.length + excludeTags.length > 0;

const updateTag = (tag: Tag, selected: boolean, include: boolean) => {
const [filter, setFilter, queryParam] = include
? [includeTags, setIncludeTags, 'includeTags']
: [excludeTags, setExcludeTags, 'excludeTags'];

// no change needed for state/url if the tag is already in the correct state
if ((selected && filter.includes(tag)) || (!selected && !filter.includes(tag))) return;

// update state
const newFilter = selected ? [...filter, tag] : filter.filter((t) => t !== tag);
setFilter(newFilter);

// update URL
const url = new URL(window.location.href);
if (newFilter.length === 0) {
url.searchParams.delete(queryParam);
} else {
url.searchParams.set(queryParam, newFilter.join(','));
}
window.history.pushState({}, '', url);
};

const toggleTags = () => {
// updateTag('bar', !includeTags.includes('bar'), true);
updateTag('bar', !excludeTags.includes('bar'), false);
};
export const TagsFilter = ({
api,
index,
updateQueryParams,
initialSelectedTags = [],
}: TagsFilterProps) => {
const [selectedTags, setSelectedTags] = useState(initialSelectedTags);
const [exclude, setExclude] = useState(false);
const [expanded, setExpanded] = useState(false);
const tagsActive = selectedTags.length > 0;

useEffect(() => {
api.experimental_setFilter(UI_FILTER, (item) => {
api.experimental_setFilter(TAGS_FILTER, (item) => {
const tags = item.tags ?? [];
if (excludeTags.some((tag) => tags.includes(tag))) return false;
if (!includeTags.every((tag) => tags.includes(tag))) return false;
return true;
return exclude
? !selectedTags.some((tag) => tags.includes(tag))
: selectedTags.every((tag) => tags.includes(tag));
});
}, [api, includeTags, excludeTags]);

const tagsParam = selectedTags.join(',');
const [includeTags, excludeTags] = exclude ? [null, tagsParam] : [tagsParam, null];
updateQueryParams({ includeTags, excludeTags });
}, [api, selectedTags, exclude, updateQueryParams]);

const allTags = Object.values(index).reduce((acc, entry) => {
if (entry.type === 'story') {
entry.tags.forEach((tag: Tag) => acc.add(tag));
}
return acc;
}, new Set<Tag>());

return (
<IconButton key="tags" title="Tag filters" active={tagsActive} onClick={toggleTags}>
<FilterIcon />
</IconButton>
<WithTooltip
placement="top"
trigger="click"
onVisibleChange={setExpanded}
tooltip={() => (
<TagsFilterPanel
allTags={Array.from(allTags)}
selectedTags={selectedTags}
exclude={exclude}
toggleTag={(tag) => {
if (selectedTags.includes(tag)) {
setSelectedTags(selectedTags.filter((t) => t !== tag));
} else {
setSelectedTags([...selectedTags, tag]);
}
}}
toggleExclude={() => setExclude(!exclude)}
/>
)}
>
<IconButton
key="tags"
title="Tag filters"
active={tagsActive}
onClick={(event) => {
event.preventDefault();
setExpanded(!expanded);
}}
>
<FilterIcon />
</IconButton>
</WithTooltip>
);
};
52 changes: 52 additions & 0 deletions code/ui/manager/src/components/sidebar/TagsFilterPanel.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test';

import { TagsFilterPanel } from './TagsFilterPanel';

const meta = {
component: TagsFilterPanel,
args: {
exclude: false,
toggleTag: fn(),
toggleExclude: fn(),
},
} satisfies Meta<typeof TagsFilterPanel>;

export default meta;

type Story = StoryObj<typeof meta>;

export const Empty: Story = {
args: {
allTags: [],
selectedTags: [],
},
};

export const Default: Story = {
args: {
allTags: ['tag1', 'tag2', 'tag3'],
selectedTags: ['tag1', 'tag3'],
},
};

export const Exclude: Story = {
args: {
...Default.args,
exclude: true,
},
};

export const BuiltInTags: Story = {
args: {
allTags: [...Default.args.allTags, 'dev', 'autodocs'],
selectedTags: ['tag1', 'tag3'],
},
};

export const BuiltInTagsSelected: Story = {
args: {
...BuiltInTags.args,
selectedTags: ['tag1', 'tag3', 'autodocs'],
},
};
135 changes: 135 additions & 0 deletions code/ui/manager/src/components/sidebar/TagsFilterPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import type { ChangeEvent } from 'react';
import React, { useState } from 'react';
import { transparentize } from 'polished';
import { styled } from '@storybook/theming';
import { CollapseIcon } from './components/CollapseIcon';

const BUILT_IN_TAGS = new Set(['dev', 'autodocs', 'test', 'attached-mdx', 'unattached-mdx']);

const CollapseButton = styled.button(({ theme }) => ({
all: 'unset',
display: 'flex',
padding: '0px 8px',
borderRadius: 4,
transition: 'color 150ms, box-shadow 150ms',
gap: 6,
alignItems: 'center',
cursor: 'pointer',
height: 28,

'&:hover, &:focus': {
outline: 'none',
background: transparentize(0.93, theme.color.secondary),
},
}));

const Text = styled.span({
'[aria-readonly=true] &': {
opacity: 0.5,
},
});

const Label = styled.label({
lineHeight: '20px',
alignItems: 'center',
marginBottom: 8,

'&:last-child': {
marginBottom: 0,
},

input: {
margin: 0,
marginRight: 6,
},
});

interface TagsFilterPanelProps {
allTags: Tag[];
selectedTags: Tag[];
exclude: boolean;
toggleTag: (tag: Tag) => void;
toggleExclude: () => void;
}

interface TagsListProps {
tags: Tag[];
selectedTags: Tag[];
toggleTag: (tag: Tag) => void;
}

const TagsList = ({ tags, selectedTags, toggleTag }: TagsListProps) => {
return tags.map((tag) => {
const checked = selectedTags.includes(tag);
const id = `tag-${tag}`;
return (
<Label key={id} htmlFor={id}>
<input
type="checkbox"
id={id}
name={id}
value={tag}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
toggleTag(e.target.value);
}}
checked={checked}
/>
<Text>{tag}</Text>
</Label>
);
});
};

const Wrapper = styled.div({
label: {
display: 'flex',
},
});

export const TagsFilterPanel = ({
allTags,
selectedTags,
exclude,
toggleTag,
toggleExclude,
}: TagsFilterPanelProps) => {
const userTags = allTags.filter((tag) => !BUILT_IN_TAGS.has(tag)).toSorted();
const builtInTags = allTags.filter((tag) => BUILT_IN_TAGS.has(tag)).toSorted();
const [builtinsExpanded, setBuiltinsExpanded] = useState(
selectedTags.some((tag) => BUILT_IN_TAGS.has(tag))
);

return (
<div>
{userTags.length === 0 ? (
'No tags defined'
) : (
<Wrapper>
Tags <span onClick={toggleExclude}>{exclude ? 'does not contain' : 'contains'}</span>
<TagsList tags={userTags} selectedTags={selectedTags} toggleTag={toggleTag} />
</Wrapper>
)}
{builtInTags.length > 0 && (
<>
<CollapseButton
type="button"
data-action="collapse-root"
onClick={(event) => {
event.preventDefault();
setBuiltinsExpanded(!builtinsExpanded);
}}
aria-expanded={builtinsExpanded}
>
<CollapseIcon isExpanded={builtinsExpanded} />
Built-in tags
</CollapseButton>
{builtinsExpanded ? (
<Wrapper>
<TagsList tags={builtInTags} selectedTags={selectedTags} toggleTag={toggleTag} />
</Wrapper>
) : null}
</>
)}
</div>
);
};

0 comments on commit be48b2b

Please sign in to comment.