feat(web): add snippet publish status filter

This commit is contained in:
JzoNg 2026-06-22 15:51:55 +08:00
parent 6928c1b4ab
commit 52383ce627
4 changed files with 168 additions and 7 deletions

View File

@ -262,6 +262,7 @@ describe('SnippetList', () => {
expect(screen.getByRole('link', { name: 'common.menus.apps' })).toHaveAttribute('href', '/apps')
expect(screen.getByRole('heading', { name: 'workflow.tabs.snippets' })).toBeInTheDocument()
expect(screen.getByText('app.studio.filters.creators')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /workflow\.common\.published \/ snippet\.draft/i })).toBeInTheDocument()
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
expect(screen.getByPlaceholderText('workflow.tabs.searchSnippets')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'snippet.create' })).toBeInTheDocument()
@ -287,6 +288,42 @@ describe('SnippetList', () => {
})
})
it('does not pass published state to the snippets list query by default', () => {
renderList()
expect(mockUseInfiniteSnippetList).toHaveBeenCalledWith(expect.not.objectContaining({
is_published: expect.any(Boolean),
}), {
enabled: true,
})
})
it('passes published state when selecting the published filter', () => {
renderList()
fireEvent.click(screen.getByRole('button', { name: /workflow\.common\.published \/ snippet\.draft/i }))
fireEvent.click(screen.getByRole('menuitemradio', { name: /workflow\.common\.published/i }))
expect(mockUseInfiniteSnippetList).toHaveBeenLastCalledWith(expect.objectContaining({
is_published: true,
}), {
enabled: true,
})
})
it('passes draft state when selecting the draft filter', () => {
renderList()
fireEvent.click(screen.getByRole('button', { name: /workflow\.common\.published \/ snippet\.draft/i }))
fireEvent.click(screen.getByRole('menuitemradio', { name: /snippet\.draft/i }))
expect(mockUseInfiniteSnippetList).toHaveBeenLastCalledWith(expect.objectContaining({
is_published: false,
}), {
enabled: true,
})
})
it('updates the search query state from the search input', () => {
renderList()

View File

@ -0,0 +1,26 @@
import { fireEvent, render, screen } from '@testing-library/react'
import SnippetPublishStatusFilter from '../snippet-publish-status-filter'
describe('SnippetPublishStatusFilter', () => {
it('should render the default published and draft filter label', () => {
render(<SnippetPublishStatusFilter value="all" onChange={vi.fn()} />)
expect(screen.getByRole('button', { name: /workflow\.common\.published \/ snippet\.draft/i })).toBeInTheDocument()
})
it('should emit the selected publish status from the dropdown', () => {
const onChange = vi.fn()
render(<SnippetPublishStatusFilter value="all" onChange={onChange} />)
fireEvent.click(screen.getByRole('button', { name: /workflow\.common\.published \/ snippet\.draft/i }))
fireEvent.click(screen.getByRole('menuitemradio', { name: /workflow\.common\.published/i }))
expect(onChange).toHaveBeenCalledWith('published')
})
it('should render the selected draft status label', () => {
render(<SnippetPublishStatusFilter value="draft" onChange={vi.fn()} />)
expect(screen.getByRole('button', { name: /snippet\.draft/i })).toBeInTheDocument()
})
})

View File

@ -0,0 +1,78 @@
'use client'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuRadioItemIndicator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
export type SnippetPublishStatus = 'all' | 'published' | 'draft'
type SnippetPublishStatusFilterProps = {
value: SnippetPublishStatus
onChange: (value: SnippetPublishStatus) => void
}
const chipClassName = 'flex h-8 items-center rounded-lg border-[0.5px] px-2 text-[13px] leading-4 outline-hidden transition-colors focus-visible:ring-2 focus-visible:ring-state-accent-solid'
const snippetPublishStatusValues: SnippetPublishStatus[] = ['all', 'published', 'draft']
const isSnippetPublishStatus = (value: string): value is SnippetPublishStatus => {
return snippetPublishStatusValues.includes(value as SnippetPublishStatus)
}
const SnippetPublishStatusFilter = ({
value,
onChange,
}: SnippetPublishStatusFilterProps) => {
const { t } = useTranslation()
const options = useMemo(() => ([
{ value: 'all', text: t('types.all', { ns: 'app' }) },
{ value: 'published', text: t('common.published', { ns: 'workflow' }) },
{ value: 'draft', text: t('draft', { ns: 'snippet' }) },
] satisfies Array<{ value: SnippetPublishStatus, text: string }>), [t])
const activeOption = options.find(option => option.value === value)
const isSelected = value !== 'all'
const defaultLabel = `${t('common.published', { ns: 'workflow' })} / ${t('draft', { ns: 'snippet' })}`
const triggerLabel = isSelected ? activeOption?.text : defaultLabel
return (
<DropdownMenu>
<DropdownMenuTrigger
render={(
<button
type="button"
className={cn(
chipClassName,
isSelected
? 'border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs hover:bg-state-base-hover'
: 'border-transparent bg-components-input-bg-normal text-text-tertiary hover:bg-components-input-bg-hover',
)}
/>
)}
>
<span className="px-1 text-text-tertiary">{triggerLabel}</span>
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary" />
</DropdownMenuTrigger>
<DropdownMenuContent placement="bottom-start" popupClassName="w-[220px]">
<DropdownMenuRadioGroup value={value} onValueChange={nextValue => isSnippetPublishStatus(nextValue) && onChange(nextValue)}>
{options.map(option => (
<DropdownMenuRadioItem key={option.value} value={option.value} closeOnClick>
<span>{option.text}</span>
<DropdownMenuRadioItemIndicator />
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
export default SnippetPublishStatusFilter

View File

@ -1,5 +1,6 @@
'use client'
import type { SnippetPublishStatus } from './components/snippet-publish-status-filter'
import type { SnippetListItem } from '@/types/snippet'
import { cn } from '@langgenius/dify-ui/cn'
import { Input } from '@langgenius/dify-ui/input'
@ -18,6 +19,7 @@ import { StudioListHeader } from '../apps/studio-list-header'
import { canAccessSnippets } from '../snippets/utils/permission'
import SnippetCard from './components/snippet-card'
import SnippetCreateButton from './components/snippet-create-button'
import SnippetPublishStatusFilter from './components/snippet-publish-status-filter'
import { SNIPPET_LIST_SEARCH_DEBOUNCE_MS } from './constants'
import { useSnippetsQueryState } from './hooks/use-snippets-query-state'
@ -27,6 +29,14 @@ const TagManagementModal = dynamic(() => import('@/features/tag-management/compo
const SNIPPET_CARD_SKELETON_KEYS = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth']
const toSnippetPublishedQuery = (publishStatus: SnippetPublishStatus) => {
if (publishStatus === 'published')
return true
if (publishStatus === 'draft')
return false
return undefined
}
type SnippetCardSkeletonProps = {
count: number
}
@ -59,16 +69,22 @@ const SnippetList = () => {
const containerRef = useRef<HTMLDivElement>(null)
const anchorRef = useRef<HTMLDivElement>(null)
const [showTagManagementModal, setShowTagManagementModal] = useState(false)
const [publishStatus, setPublishStatus] = useState<SnippetPublishStatus>('all')
useDocumentTitle(t('tabs.snippets', { ns: 'workflow' }))
const snippetListQuery = useMemo(() => ({
page: 1,
limit: 30,
keyword: debouncedKeywords,
...(tagIDs.length ? { tag_ids: tagIDs } : {}),
...(creatorIDs.length ? { creator_ids: creatorIDs } : {}),
}), [creatorIDs, debouncedKeywords, tagIDs])
const snippetListQuery = useMemo(() => {
const isPublished = toSnippetPublishedQuery(publishStatus)
return {
page: 1,
limit: 30,
keyword: debouncedKeywords,
...(tagIDs.length ? { tag_ids: tagIDs } : {}),
...(creatorIDs.length ? { creator_ids: creatorIDs } : {}),
...(typeof isPublished === 'boolean' ? { is_published: isPublished } : {}),
}
}, [creatorIDs, debouncedKeywords, publishStatus, tagIDs])
const canQuerySnippetList = canAccessSnippets(workspacePermissionKeys)
const {
@ -144,6 +160,10 @@ const SnippetList = () => {
value={creatorIDs}
onChange={setCreatorIDs}
/>
<SnippetPublishStatusFilter
value={publishStatus}
onChange={setPublishStatus}
/>
<TagFilter type="snippet" value={tagIDs} onChange={setTagIDs} onOpenTagManagement={() => setShowTagManagementModal(true)} />
<div className="relative w-50">
<span aria-hidden className="pointer-events-none absolute top-1/2 left-2 i-ri-search-line size-4 -translate-y-1/2 text-components-input-text-placeholder" />