feat(web): add snippet

This commit is contained in:
JzoNg
2026-05-23 10:20:10 +08:00
parent f1527ef7c1
commit 930da499d1
8 changed files with 188 additions and 83 deletions

View File

@@ -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<typeof import('ahooks')>('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 ? <div data-testid="create-snippet-dialog" /> : null,
vi.mock('../snippet-tags-filter', () => ({
default: ({
value,
onChange,
}: {
value: string[]
onChange: (value: string[]) => void
}) => (
<button type="button" onClick={() => onChange(['tag-1'])}>
{`tag-filter:${value.join(',')}`}
</button>
),
}))
describe('Snippets', () => {
@@ -64,6 +60,7 @@ describe('Snippets', () => {
render(<Snippets searchText="" />)
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(<Snippets searchText="" />)
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', () => {

View File

@@ -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(<SnippetEmptyState onCreate={handleCreate} />)
it('should render empty state copy without create action', () => {
render(<SnippetEmptyState />)
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(<SnippetEmptyState onCreate={handleCreate} />)
fireEvent.click(screen.getByRole('button', { name: 'workflow.tabs.createSnippet' }))
expect(handleCreate).toHaveBeenCalledTimes(1)
expect(screen.queryByRole('button', { name: 'workflow.tabs.createSnippet' })).not.toBeInTheDocument()
})
})
})

View File

@@ -23,7 +23,7 @@ describe('SnippetListItem', () => {
})
describe('Rendering', () => {
it('should render snippet name', () => {
it('should render snippet title and description', () => {
render(
<SnippetListItem
snippet={createSnippet()}
@@ -34,19 +34,24 @@ describe('SnippetListItem', () => {
)
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(
<SnippetListItem
snippet={createSnippet()}
isHovered
snippet={createSnippet({
tags: [{ id: 'tag-1', name: 'Search', type: 'snippet', binding_count: 1 }],
})}
isHovered={false}
onMouseEnter={vi.fn()}
onMouseLeave={vi.fn()}
/>,
)
expect(screen.getByText('Customer Review')).toBeInTheDocument()
expect(screen.queryByText('Search')).not.toBeInTheDocument()
expect(screen.queryByText('3')).not.toBeInTheDocument()
})
})

View File

@@ -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<HTMLDivElement>(null)
const [hoveredSnippetId, setHoveredSnippetId] = useState<string | null>(null)
const [tagIds, setTagIds] = useState<string[]>([])
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 <LoadingSkeleton />
const tagsFilter = (
<div className="flex justify-end border-b border-divider-subtle px-2 py-2">
<SnippetTagsFilter value={tagIds} onChange={setTagIds} />
</div>
)
if (loading || isLoading || (isFetching && snippets.length === 0)) {
return (
<>
{tagsFilter}
<LoadingSkeleton />
</>
)
}
return (
<>
{tagsFilter}
{!snippets.length
? (
<SnippetEmptyState onCreate={handleOpenCreateSnippetDialog} />
<SnippetEmptyState />
)
: (
<ScrollAreaRoot className="relative max-h-120 max-w-125 overflow-hidden">
@@ -175,12 +181,6 @@ const Snippets = ({
</ScrollAreaScrollbar>
</ScrollAreaRoot>
)}
<CreateSnippetDialog
isOpen={isCreateSnippetDialogOpen}
isSubmitting={isCreatingSnippet || createSnippetMutation.isPending}
onClose={handleCloseCreateSnippetDialog}
onConfirm={handleCreateSnippet}
/>
</>
)
}

View File

@@ -59,13 +59,13 @@ const SnippetDetailCard: FC<SnippetDetailCardProps> = ({
}, [workflow?.graph])
return (
<div className="w-[224px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur px-3 pt-3 pb-4 shadow-lg backdrop-blur-[5px]">
<div className="w-56 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur px-3 pt-3 pb-4 shadow-lg backdrop-blur-[5px]">
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-2">
<div className="system-md-medium text-text-primary">{name}</div>
</div>
{!!description && (
<div className="w-[200px] system-xs-regular text-text-secondary">
<div className="w-50 system-xs-regular text-text-secondary">
{description}
</div>
)}

View File

@@ -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<SnippetEmptyStateProps> = ({
onCreate,
}) => {
const SnippetEmptyState = () => {
const { t } = useTranslation()
return (
<div className="flex min-h-[480px] flex-col items-center justify-center gap-2 px-4">
<div className="flex min-h-120 flex-col items-center justify-center gap-2 px-4">
<span className="i-custom-vender-line-others-search-menu h-8 w-8 text-text-tertiary" />
<div className="system-sm-regular text-text-secondary">
{t('tabs.noSnippetsFound', { ns: 'workflow' })}
</div>
<Button
variant="secondary-accent"
size="small"
onClick={onCreate}
>
{t('tabs.createSnippet', { ns: 'workflow' })}
</Button>
</div>
)
}

View File

@@ -22,15 +22,20 @@ const SnippetListItem = ({
<div
ref={ref}
className={cn(
'flex h-8 cursor-pointer items-center rounded-lg px-3',
'flex cursor-pointer flex-col gap-1 rounded-xl px-3 py-2',
isHovered && 'bg-background-default-hover',
className,
)}
{...props}
>
<div className="min-w-0 system-sm-medium text-text-secondary">
<div className="w-full truncate system-md-semibold text-text-secondary">
{snippet.name}
</div>
{!!snippet.description && (
<div className="line-clamp-1 w-full system-sm-regular text-text-tertiary">
{snippet.description}
</div>
)}
</div>
)
}

View File

@@ -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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={(
<button
type="button"
aria-label={triggerLabel}
className={cn(
'relative flex h-8 min-w-8 cursor-pointer items-center justify-center rounded-lg border-[0.5px] border-components-panel-border bg-components-input-bg-normal px-2 text-text-tertiary hover:bg-components-input-bg-hover',
open && 'border-components-input-border-active bg-components-input-bg-active text-text-secondary',
value.length > 0 && 'text-text-secondary',
)}
>
<Tag01Icon className="size-4" aria-hidden="true" />
{value.length > 0 && (
<span className="ml-1 system-xs-medium text-text-secondary">
{value.length}
</span>
)}
</button>
)}
/>
<PopoverContent
placement="bottom-end"
sideOffset={6}
popupClassName="w-[240px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-0 shadow-lg backdrop-blur-xs"
>
<div className="p-2 pb-1">
<div className="relative">
<span className="absolute top-1/2 left-2 i-ri-search-line size-4 -translate-y-1/2 text-components-input-text-placeholder" aria-hidden="true" />
<Input
className="pl-6.5"
value={searchText}
onChange={event => setSearchText(event.target.value)}
placeholder={t('searchTags', { ns: 'pluginTags' }) || ''}
/>
</div>
</div>
<CheckboxGroup
aria-label={t('allTags', { ns: 'pluginTags' })}
value={value}
onValueChange={onChange}
className="max-h-112 overflow-y-auto p-1"
>
{filteredTags.map(tag => (
<label
key={tag.id}
className="flex h-7 cursor-pointer items-center rounded-lg px-2 py-1.5 select-none hover:bg-state-base-hover"
>
<Checkbox className="mr-1" value={tag.id} />
<div className="px-1 system-sm-medium text-text-secondary">
{tag.name}
</div>
</label>
))}
{!filteredTags.length && (
<div className="px-3 py-2 system-xs-regular text-text-tertiary">
{t('tag.noTag', { ns: 'common' })}
</div>
)}
</CheckboxGroup>
</PopoverContent>
</Popover>
)
}
export default SnippetTagsFilter