diff --git a/web/app/components/snippet-list/__tests__/index.spec.tsx b/web/app/components/snippet-list/__tests__/index.spec.tsx index dca361764eb..9fad5bc2a4f 100644 --- a/web/app/components/snippet-list/__tests__/index.spec.tsx +++ b/web/app/components/snippet-list/__tests__/index.spec.tsx @@ -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() diff --git a/web/app/components/snippet-list/components/__tests__/snippet-publish-status-filter.spec.tsx b/web/app/components/snippet-list/components/__tests__/snippet-publish-status-filter.spec.tsx new file mode 100644 index 00000000000..c9ffbf9df0f --- /dev/null +++ b/web/app/components/snippet-list/components/__tests__/snippet-publish-status-filter.spec.tsx @@ -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() + + 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() + + 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() + + expect(screen.getByRole('button', { name: /snippet\.draft/i })).toBeInTheDocument() + }) +}) diff --git a/web/app/components/snippet-list/components/snippet-publish-status-filter.tsx b/web/app/components/snippet-list/components/snippet-publish-status-filter.tsx new file mode 100644 index 00000000000..4828a555ab9 --- /dev/null +++ b/web/app/components/snippet-list/components/snippet-publish-status-filter.tsx @@ -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 ( + + + )} + > + {triggerLabel} + + + + isSnippetPublishStatus(nextValue) && onChange(nextValue)}> + {options.map(option => ( + + {option.text} + + + ))} + + + + ) +} + +export default SnippetPublishStatusFilter diff --git a/web/app/components/snippet-list/index.tsx b/web/app/components/snippet-list/index.tsx index e62216a7c4a..8437a02a923 100644 --- a/web/app/components/snippet-list/index.tsx +++ b/web/app/components/snippet-list/index.tsx @@ -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(null) const anchorRef = useRef(null) const [showTagManagementModal, setShowTagManagementModal] = useState(false) + const [publishStatus, setPublishStatus] = useState('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} /> + setShowTagManagementModal(true)} />