diff --git a/web/app/components/workflow/block-selector/snippets/__tests__/index.spec.tsx b/web/app/components/workflow/block-selector/snippets/__tests__/index.spec.tsx index 99ccf2aa09..c6ff6be8c7 100644 --- a/web/app/components/workflow/block-selector/snippets/__tests__/index.spec.tsx +++ b/web/app/components/workflow/block-selector/snippets/__tests__/index.spec.tsx @@ -1,11 +1,8 @@ -import { fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import Snippets from '../index' const mockUseInfiniteSnippetList = vi.fn() const mockHandleInsertSnippet = vi.fn() -const mockHandleCreateSnippet = vi.fn() -const mockHandleOpenCreateSnippetDialog = vi.fn() -const mockHandleCloseCreateSnippetDialog = vi.fn() vi.mock('ahooks', async () => { const actual = await vi.importActual('ahooks') @@ -25,19 +22,18 @@ vi.mock('../use-insert-snippet', () => ({ }), })) -vi.mock('@/app/components/snippets/hooks/use-create-snippet', () => ({ - useCreateSnippet: () => ({ - createSnippetMutation: { isPending: false }, - handleCloseCreateSnippetDialog: mockHandleCloseCreateSnippetDialog, - handleCreateSnippet: mockHandleCreateSnippet, - handleOpenCreateSnippetDialog: mockHandleOpenCreateSnippetDialog, - isCreateSnippetDialogOpen: false, - isCreatingSnippet: false, - }), -})) - -vi.mock('@/app/components/snippets/create-snippet-dialog', () => ({ - default: ({ isOpen }: { isOpen: boolean }) => isOpen ?
: null, +vi.mock('../snippet-tags-filter', () => ({ + default: ({ + value, + onChange, + }: { + value: string[] + onChange: (value: string[]) => void + }) => ( + + ), })) describe('Snippets', () => { @@ -64,6 +60,7 @@ describe('Snippets', () => { render() expect(screen.getByText('workflow.tabs.noSnippetsFound')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'workflow.tabs.createSnippet' })).not.toBeInTheDocument() }) it('should render snippet rows from infinite list data', () => { @@ -107,12 +104,19 @@ describe('Snippets', () => { }) describe('User Interactions', () => { - it('should delegate create action from empty state', () => { + it('should filter snippets by selected snippet tags', async () => { render() - fireEvent.click(screen.getByRole('button', { name: 'workflow.tabs.createSnippet' })) + fireEvent.click(screen.getByRole('button', { name: 'tag-filter:' })) - expect(mockHandleOpenCreateSnippetDialog).toHaveBeenCalledTimes(1) + await waitFor(() => { + expect(mockUseInfiniteSnippetList).toHaveBeenLastCalledWith({ + page: 1, + limit: 30, + tag_ids: ['tag-1'], + is_published: true, + }) + }) }) it('should delegate insert action when snippet item is clicked', () => { diff --git a/web/app/components/workflow/block-selector/snippets/__tests__/snippet-empty-state.spec.tsx b/web/app/components/workflow/block-selector/snippets/__tests__/snippet-empty-state.spec.tsx index 1ab2f712bf..91567b79fe 100644 --- a/web/app/components/workflow/block-selector/snippets/__tests__/snippet-empty-state.spec.tsx +++ b/web/app/components/workflow/block-selector/snippets/__tests__/snippet-empty-state.spec.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from '@testing-library/react' +import { render, screen } from '@testing-library/react' import SnippetEmptyState from '../snippet-empty-state' describe('SnippetEmptyState', () => { @@ -7,25 +7,11 @@ describe('SnippetEmptyState', () => { }) describe('Rendering', () => { - it('should render empty state copy and create action', () => { - const handleCreate = vi.fn() - - render() + it('should render empty state copy without create action', () => { + render() expect(screen.getByText('workflow.tabs.noSnippetsFound')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'workflow.tabs.createSnippet' })).toBeInTheDocument() - }) - }) - - describe('User Interactions', () => { - it('should call onCreate when create button is clicked', () => { - const handleCreate = vi.fn() - - render() - - fireEvent.click(screen.getByRole('button', { name: 'workflow.tabs.createSnippet' })) - - expect(handleCreate).toHaveBeenCalledTimes(1) + expect(screen.queryByRole('button', { name: 'workflow.tabs.createSnippet' })).not.toBeInTheDocument() }) }) }) diff --git a/web/app/components/workflow/block-selector/snippets/__tests__/snippet-list-item.spec.tsx b/web/app/components/workflow/block-selector/snippets/__tests__/snippet-list-item.spec.tsx index 47884b8866..6adbe547f8 100644 --- a/web/app/components/workflow/block-selector/snippets/__tests__/snippet-list-item.spec.tsx +++ b/web/app/components/workflow/block-selector/snippets/__tests__/snippet-list-item.spec.tsx @@ -23,7 +23,7 @@ describe('SnippetListItem', () => { }) describe('Rendering', () => { - it('should render snippet name', () => { + it('should render snippet title and description', () => { render( { ) expect(screen.getByText('Customer Review')).toBeInTheDocument() + expect(screen.getByText('Snippet description')).toBeInTheDocument() }) - it('should not render metadata when hovered', () => { + it('should not render metadata or tags', () => { render( , ) expect(screen.getByText('Customer Review')).toBeInTheDocument() + expect(screen.queryByText('Search')).not.toBeInTheDocument() + expect(screen.queryByText('3')).not.toBeInTheDocument() }) }) diff --git a/web/app/components/workflow/block-selector/snippets/index.tsx b/web/app/components/workflow/block-selector/snippets/index.tsx index eb26cea75e..908917912a 100644 --- a/web/app/components/workflow/block-selector/snippets/index.tsx +++ b/web/app/components/workflow/block-selector/snippets/index.tsx @@ -22,12 +22,11 @@ import { useState, } from 'react' import Loading from '@/app/components/base/loading' -import CreateSnippetDialog from '@/app/components/snippets/create-snippet-dialog' -import { useCreateSnippet } from '@/app/components/snippets/hooks/use-create-snippet' import { useInfiniteSnippetList } from '@/service/use-snippets' import SnippetDetailCard from './snippet-detail-card' import SnippetEmptyState from './snippet-empty-state' import SnippetListItem from './snippet-list-item' +import SnippetTagsFilter from './snippet-tags-filter' import { useInsertSnippet } from './use-insert-snippet' type SnippetsProps = { @@ -66,18 +65,11 @@ const Snippets = ({ insertPayload, onInserted, }: SnippetsProps) => { - const { - createSnippetMutation, - handleCloseCreateSnippetDialog, - handleCreateSnippet, - handleOpenCreateSnippetDialog, - isCreateSnippetDialogOpen, - isCreatingSnippet, - } = useCreateSnippet() const { handleInsertSnippet } = useInsertSnippet() const deferredSearchText = useDeferredValue(searchText) const viewportRef = useRef(null) const [hoveredSnippetId, setHoveredSnippetId] = useState(null) + const [tagIds, setTagIds] = useState([]) const keyword = deferredSearchText.trim() || undefined @@ -92,6 +84,7 @@ const Snippets = ({ page: 1, limit: 30, keyword, + ...(tagIds.length ? { tag_ids: tagIds } : {}), is_published: true, }) @@ -117,18 +110,31 @@ const Snippets = ({ { target: viewportRef, isNoMore: () => isNoMore, - reloadDeps: [isNoMore, isFetchingNextPage, keyword], + reloadDeps: [isNoMore, isFetchingNextPage, keyword, tagIds], }, ) - if (loading || isLoading || (isFetching && snippets.length === 0)) - return + const tagsFilter = ( +
+ +
+ ) + + if (loading || isLoading || (isFetching && snippets.length === 0)) { + return ( + <> + {tagsFilter} + + + ) + } return ( <> + {tagsFilter} {!snippets.length ? ( - + ) : ( @@ -175,12 +181,6 @@ const Snippets = ({ )} - ) } diff --git a/web/app/components/workflow/block-selector/snippets/snippet-detail-card.tsx b/web/app/components/workflow/block-selector/snippets/snippet-detail-card.tsx index 6df02c1791..810f0b9d19 100644 --- a/web/app/components/workflow/block-selector/snippets/snippet-detail-card.tsx +++ b/web/app/components/workflow/block-selector/snippets/snippet-detail-card.tsx @@ -59,13 +59,13 @@ const SnippetDetailCard: FC = ({ }, [workflow?.graph]) return ( -
+
{name}
{!!description && ( -
+
{description}
)} diff --git a/web/app/components/workflow/block-selector/snippets/snippet-empty-state.tsx b/web/app/components/workflow/block-selector/snippets/snippet-empty-state.tsx index 9ba4c74915..df76a1ff55 100644 --- a/web/app/components/workflow/block-selector/snippets/snippet-empty-state.tsx +++ b/web/app/components/workflow/block-selector/snippets/snippet-empty-state.tsx @@ -1,29 +1,14 @@ -import type { FC } from 'react' -import { Button } from '@langgenius/dify-ui/button' import { useTranslation } from 'react-i18next' -type SnippetEmptyStateProps = { - onCreate: () => void -} - -const SnippetEmptyState: FC = ({ - onCreate, -}) => { +const SnippetEmptyState = () => { const { t } = useTranslation() return ( -
+
{t('tabs.noSnippetsFound', { ns: 'workflow' })}
-
) } diff --git a/web/app/components/workflow/block-selector/snippets/snippet-list-item.tsx b/web/app/components/workflow/block-selector/snippets/snippet-list-item.tsx index 402ca9a843..c2042c2591 100644 --- a/web/app/components/workflow/block-selector/snippets/snippet-list-item.tsx +++ b/web/app/components/workflow/block-selector/snippets/snippet-list-item.tsx @@ -22,15 +22,20 @@ const SnippetListItem = ({
-
+
{snippet.name}
+ {!!snippet.description && ( +
+ {snippet.description} +
+ )}
) } diff --git a/web/app/components/workflow/block-selector/snippets/snippet-tags-filter.tsx b/web/app/components/workflow/block-selector/snippets/snippet-tags-filter.tsx new file mode 100644 index 0000000000..70c940aadb --- /dev/null +++ b/web/app/components/workflow/block-selector/snippets/snippet-tags-filter.tsx @@ -0,0 +1,120 @@ +import { Checkbox } from '@langgenius/dify-ui/checkbox' +import { CheckboxGroup } from '@langgenius/dify-ui/checkbox-group' +import { cn } from '@langgenius/dify-ui/cn' +import { Input } from '@langgenius/dify-ui/input' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@langgenius/dify-ui/popover' +import { useQuery } from '@tanstack/react-query' +import { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import Tag01Icon from '@/app/components/base/icons/src/vender/line/financeAndECommerce/Tag01' +import { consoleQuery } from '@/service/client' + +type SnippetTagsFilterProps = { + value: string[] + onChange: (value: string[]) => void +} + +const SnippetTagsFilter = ({ + value, + onChange, +}: SnippetTagsFilterProps) => { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + const [searchText, setSearchText] = useState('') + + const { data: tagList = [] } = useQuery(consoleQuery.tags.list.queryOptions({ + input: { + query: { + type: 'snippet', + }, + }, + })) + + const tagById = useMemo(() => new Map(tagList.map(tag => [tag.id, tag])), [tagList]) + const filteredTags = useMemo(() => { + const normalizedSearch = searchText.trim().toLowerCase() + if (!normalizedSearch) + return tagList + + return tagList.filter(tag => tag.name.toLowerCase().includes(normalizedSearch)) + }, [searchText, tagList]) + + const selectedTags = value.flatMap((tagId) => { + const tag = tagById.get(tagId) + return tag ? [tag] : [] + }) + const triggerLabel = selectedTags.length + ? selectedTags.map(tag => tag.name).join(', ') + : t('tag.placeholder', { ns: 'common' }) + + return ( + + 0 && 'text-text-secondary', + )} + > + + ) +} + +export default SnippetTagsFilter