mirror of
https://github.com/langgenius/dify.git
synced 2026-06-01 13:00:48 -04:00
feat(web): add snippet
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user