-
-
Notifications
You must be signed in to change notification settings - Fork 9.4k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
TagsFilter: Initial implementation and stories
- Loading branch information
Showing
5 changed files
with
312 additions
and
45 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
44 changes: 44 additions & 0 deletions
44
code/ui/manager/src/components/sidebar/TagsFilter.stories.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
52
code/ui/manager/src/components/sidebar/TagsFilterPanel.stories.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
135
code/ui/manager/src/components/sidebar/TagsFilterPanel.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
}; |