refactor(web): snippet list page

This commit is contained in:
JzoNg
2026-05-22 18:06:29 +08:00
parent 75daf8e61b
commit 2bb3b439e0
17 changed files with 647 additions and 486 deletions

View File

@@ -1,10 +1,10 @@
import Apps from '@/app/components/apps'
import SnippetPlanGuard from '@/app/components/billing/snippet-plan-guard'
import SnippetList from '@/app/components/snippet-list'
const SnippetsPage = () => {
return (
<SnippetPlanGuard fallbackHref="/apps">
<Apps pageType="snippets" />
<SnippetList />
</SnippetPlanGuard>
)
}

View File

@@ -0,0 +1,259 @@
import { fireEvent, screen } from '@testing-library/react'
import * as React from 'react'
import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features'
import { renderWithNuqs } from '@/test/nuqs-testing'
import SnippetList from '..'
const mockUseInfiniteSnippetList = vi.hoisted(() => vi.fn())
const mockSetKeywords = vi.hoisted(() => vi.fn())
const mockSetTagIDs = vi.hoisted(() => vi.fn())
const mockSetCreatorID = vi.hoisted(() => vi.fn())
const mockQueryState = vi.hoisted(() => ({
tagIDs: [] as string[],
keywords: '',
creatorID: '',
}))
vi.mock('@/service/use-snippets', () => ({
useCreateSnippetMutation: () => ({
mutate: vi.fn(),
isPending: false,
}),
useInfiniteSnippetList: (params: unknown, options: unknown) => mockUseInfiniteSnippetList(params, options),
}))
vi.mock('../hooks/use-snippets-query-state', () => ({
useSnippetsQueryState: () => ({
query: mockQueryState,
setKeywords: mockSetKeywords,
setTagIDs: mockSetTagIDs,
setCreatorID: mockSetCreatorID,
}),
}))
vi.mock('@/service/client', () => ({
consoleClient: {
systemFeatures: vi.fn(),
},
consoleQuery: {
tags: {
list: {
queryOptions: (options: unknown) => options,
},
},
systemFeatures: {
queryKey: () => ['console', 'systemFeatures'],
},
},
}))
const mockIsCurrentWorkspaceEditor = vi.fn(() => true)
const mockIsCurrentWorkspaceDatasetOperator = vi.fn(() => false)
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor(),
isCurrentWorkspaceDatasetOperator: mockIsCurrentWorkspaceDatasetOperator(),
isLoadingCurrentWorkspace: false,
userProfile: { id: 'creator-1' },
}),
}))
vi.mock('@/service/use-common', () => ({
useMembers: () => ({
data: {
accounts: [
{ id: 'creator-1', name: 'Alice', avatar_url: null, status: 'active' },
{ id: 'creator-2', name: 'Bob', avatar_url: null, status: 'active' },
],
},
}),
}))
vi.mock('@/next/navigation', () => ({
useRouter: () => ({ push: vi.fn() }),
useSearchParams: () => new URLSearchParams(''),
}))
vi.mock('@/next/dynamic', () => ({
default: () => {
return function MockDynamicComponent() {
return React.createElement('div', { 'data-testid': 'tag-management-modal' })
}
},
}))
vi.mock('@/app/components/workflow/create-snippet-dialog', () => ({
default: () => null,
}))
vi.mock('@/hooks/use-document-title', () => ({
default: vi.fn(),
}))
const mockObserve = vi.fn()
const mockDisconnect = vi.fn()
beforeAll(() => {
globalThis.IntersectionObserver = class MockIntersectionObserver {
constructor(_callback: IntersectionObserverCallback) {}
observe = mockObserve
disconnect = mockDisconnect
unobserve = vi.fn()
root = null
rootMargin = ''
thresholds = []
takeRecords = () => []
} as unknown as typeof IntersectionObserver
})
const mockRefetch = vi.fn()
const mockFetchNextPage = vi.fn()
const mockSnippetListState = {
data: {
pages: [{
data: [
{
id: 'snippet-1',
name: 'Sales Snippet',
description: 'Builds a sales follow-up.',
type: 'node',
is_published: true,
use_count: 12,
icon_info: {
icon_type: 'emoji',
icon: '🤖',
icon_background: '#E4FBCC',
},
created_at: 1704067200,
created_by: 'creator-1',
updated_at: 1704153600,
updated_by: 'creator-2',
},
],
page: 1,
limit: 30,
total: 1,
has_more: false,
}],
},
isLoading: false,
isFetching: false,
isFetchingNextPage: false,
hasNextPage: false,
error: null as Error | null,
}
const renderList = () => {
const { wrapper: SystemFeaturesWrapper } = createSystemFeaturesWrapper({
systemFeatures: { branding: { enabled: false } },
})
return renderWithNuqs(
<SystemFeaturesWrapper>
<SnippetList />
</SystemFeaturesWrapper>,
)
}
describe('SnippetList', () => {
beforeEach(() => {
vi.clearAllMocks()
mockQueryState.tagIDs = []
mockQueryState.keywords = ''
mockQueryState.creatorID = ''
mockIsCurrentWorkspaceEditor.mockReturnValue(true)
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(false)
mockUseInfiniteSnippetList.mockReturnValue({
...mockSnippetListState,
refetch: mockRefetch,
fetchNextPage: mockFetchNextPage,
})
})
it('renders the dedicated snippets list layout', () => {
renderList()
expect(screen.getByText('app.studio.filters.allCreators')).toBeInTheDocument()
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
expect(screen.getByPlaceholderText('workflow.tabs.searchSnippets')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'snippet.create' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /Sales Snippet/ })).toHaveAttribute('href', '/snippets/snippet-1/orchestrate')
expect(screen.getByTestId('tag-management-modal')).toBeInTheDocument()
})
it('passes creator, tag, and search filters to the snippets list query', () => {
mockQueryState.tagIDs = ['tag-1', 'tag-2']
mockQueryState.keywords = 'sales'
mockQueryState.creatorID = 'creator-1'
renderList()
expect(mockUseInfiniteSnippetList).toHaveBeenCalledWith({
page: 1,
limit: 30,
keyword: 'sales',
tag_ids: ['tag-1', 'tag-2'],
creator_id: 'creator-1',
}, {
enabled: true,
})
})
it('updates the search query state from the search input', () => {
renderList()
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'summary' } })
expect(mockSetKeywords).toHaveBeenCalledWith('summary')
})
it('clears the search query state', () => {
mockQueryState.keywords = 'summary'
renderList()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' }))
expect(mockSetKeywords).toHaveBeenCalledWith('')
})
it('updates the creator query state as a single creator filter', () => {
renderList()
fireEvent.click(screen.getByRole('button', { name: 'app.studio.filters.allCreators' }))
fireEvent.click(screen.getByRole('button', { name: /Bob/ }))
expect(mockSetCreatorID).toHaveBeenCalledWith('creator-2')
})
it('hides the create button for non-editors', () => {
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
renderList()
expect(screen.queryByRole('button', { name: 'snippet.create' })).not.toBeInTheDocument()
})
it('shows an empty state when no snippets are returned', () => {
mockUseInfiniteSnippetList.mockReturnValue({
...mockSnippetListState,
data: {
pages: [{
data: [],
page: 1,
limit: 30,
total: 0,
has_more: false,
}],
},
refetch: mockRefetch,
fetchNextPage: mockFetchNextPage,
})
renderList()
expect(screen.getByText('workflow.tabs.noSnippetsFound')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,76 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import SnippetCreateButton from '../snippet-create-button'
const { mockPush, mockCreateMutate, mockToastSuccess, mockToastError } = vi.hoisted(() => ({
mockPush: vi.fn(),
mockCreateMutate: vi.fn(),
mockToastSuccess: vi.fn(),
mockToastError: vi.fn(),
}))
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: mockPush,
}),
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
success: mockToastSuccess,
error: mockToastError,
},
}))
vi.mock('@/service/use-snippets', () => ({
useCreateSnippetMutation: () => ({
mutate: mockCreateMutate,
isPending: false,
}),
}))
describe('SnippetCreateButton', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should open the create dialog and create a snippet from the modal', async () => {
mockCreateMutate.mockImplementation((_payload, options?: { onSuccess?: (snippet: { id: string }) => void }) => {
options?.onSuccess?.({ id: 'snippet-123' })
})
render(<SnippetCreateButton />)
fireEvent.click(screen.getByRole('button', { name: 'snippet.create' }))
expect(screen.getByText('workflow.snippet.createDialogTitle')).toBeInTheDocument()
fireEvent.change(screen.getByPlaceholderText('workflow.snippet.namePlaceholder'), {
target: { value: 'My Snippet' },
})
fireEvent.change(screen.getByPlaceholderText('workflow.snippet.descriptionPlaceholder'), {
target: { value: 'Useful snippet description' },
})
fireEvent.click(screen.getByRole('button', { name: /workflow\.snippet\.confirm/i }))
expect(mockCreateMutate).toHaveBeenCalledWith({
body: {
name: 'My Snippet',
description: 'Useful snippet description',
icon_info: {
icon: '🤖',
icon_type: 'emoji',
icon_background: '#FFEAD5',
icon_url: undefined,
},
},
}, expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}))
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith('/snippets/snippet-123/orchestrate')
})
expect(mockToastSuccess).toHaveBeenCalledWith('workflow.snippet.createSuccess')
})
})

View File

@@ -0,0 +1,75 @@
'use client'
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
import { Button } from '@langgenius/dify-ui/button'
import { toast } from '@langgenius/dify-ui/toast'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import CreateSnippetDialog from '@/app/components/workflow/create-snippet-dialog'
import { useRouter } from '@/next/navigation'
import {
useCreateSnippetMutation,
} from '@/service/use-snippets'
const SnippetCreateButton = () => {
const { t } = useTranslation('snippet')
const { push } = useRouter()
const createSnippetMutation = useCreateSnippetMutation()
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
const handleCreateSnippet = ({
name,
description,
icon,
}: {
name: string
description: string
icon: AppIconSelection
}) => {
createSnippetMutation.mutate({
body: {
name,
description: description || undefined,
icon_info: {
icon: icon.type === 'emoji' ? icon.icon : icon.fileId,
icon_type: icon.type,
icon_background: icon.type === 'emoji' ? icon.background : undefined,
icon_url: icon.type === 'image' ? icon.url : undefined,
},
},
}, {
onSuccess: (snippet) => {
toast.success(t('snippet.createSuccess', { ns: 'workflow' }))
setIsCreateDialogOpen(false)
push(`/snippets/${snippet.id}/orchestrate`)
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : t('createFailed'))
},
})
}
return (
<>
<Button
variant="primary"
disabled={createSnippetMutation.isPending}
onClick={() => setIsCreateDialogOpen(true)}
>
<span aria-hidden className="mr-0.5 i-ri-add-line size-4" />
<span>{t('create')}</span>
</Button>
{isCreateDialogOpen && (
<CreateSnippetDialog
isOpen={isCreateDialogOpen}
isSubmitting={createSnippetMutation.isPending}
onClose={() => setIsCreateDialogOpen(false)}
onConfirm={handleCreateSnippet}
/>
)}
</>
)
}
export default SnippetCreateButton

View File

@@ -0,0 +1 @@
export const SNIPPET_LIST_SEARCH_DEBOUNCE_MS = 500

View File

@@ -0,0 +1,38 @@
import { debounce, parseAsArrayOf, parseAsString, useQueryStates } from 'nuqs'
import { useCallback, useMemo } from 'react'
import { SNIPPET_LIST_SEARCH_DEBOUNCE_MS } from '../constants'
const snippetListQueryParsers = {
tagIDs: parseAsArrayOf(parseAsString, ';')
.withDefault([])
.withOptions({ history: 'push' }),
keywords: parseAsString.withDefault('').withOptions({
limitUrlUpdates: debounce(SNIPPET_LIST_SEARCH_DEBOUNCE_MS),
}),
creatorID: parseAsString
.withDefault('')
.withOptions({ history: 'push' }),
}
export function useSnippetsQueryState() {
const [query, setQuery] = useQueryStates(snippetListQueryParsers)
const setKeywords = useCallback((keywords: string) => {
setQuery({ keywords })
}, [setQuery])
const setTagIDs = useCallback((tagIDs: string[]) => {
setQuery({ tagIDs })
}, [setQuery])
const setCreatorID = useCallback((creatorID: string) => {
setQuery({ creatorID })
}, [setQuery])
return useMemo(() => ({
query,
setKeywords,
setTagIDs,
setCreatorID,
}), [query, setCreatorID, setKeywords, setTagIDs])
}

View File

@@ -0,0 +1,189 @@
'use client'
import type { SnippetListItem } from '@/types/snippet'
import { cn } from '@langgenius/dify-ui/cn'
import { Input } from '@langgenius/dify-ui/input'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useDebounce } from 'ahooks'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useAppContext } from '@/context/app-context'
import { TagFilter } from '@/features/tag-management/components/tag-filter'
import useDocumentTitle from '@/hooks/use-document-title'
import dynamic from '@/next/dynamic'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useInfiniteSnippetList } from '@/service/use-snippets'
import CreatorsFilter from '../apps/creators-filter'
import Empty from '../apps/empty'
import Footer from '../apps/footer'
import SnippetCard from './components/snippet-card'
import SnippetCreateButton from './components/snippet-create-button'
import { SNIPPET_LIST_SEARCH_DEBOUNCE_MS } from './constants'
import { useSnippetsQueryState } from './hooks/use-snippets-query-state'
const TagManagementModal = dynamic(() => import('@/features/tag-management/components/tag-management-modal').then(mod => mod.TagManagementModal), {
ssr: false,
})
const SNIPPET_CARD_SKELETON_KEYS = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth']
type SnippetCardSkeletonProps = {
count: number
}
const SnippetCardSkeleton = ({ count }: SnippetCardSkeletonProps) => {
return (
<>
{SNIPPET_CARD_SKELETON_KEYS.slice(0, count).map(key => (
<div
key={key}
className="col-span-1 h-40 animate-pulse rounded-xl bg-background-default-lighter"
/>
))}
</>
)
}
const SnippetList = () => {
const { t } = useTranslation()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext()
// eslint-disable-next-line react/use-state -- custom URL query hook, not React.useState
const {
query: { tagIDs, keywords, creatorID },
setKeywords,
setTagIDs,
setCreatorID,
} = useSnippetsQueryState()
const debouncedKeywords = useDebounce(keywords, { wait: SNIPPET_LIST_SEARCH_DEBOUNCE_MS })
const containerRef = useRef<HTMLDivElement>(null)
const anchorRef = useRef<HTMLDivElement>(null)
const [showTagManagementModal, setShowTagManagementModal] = useState(false)
useDocumentTitle(t('tabs.snippets', { ns: 'workflow' }))
const snippetListQuery = useMemo(() => ({
page: 1,
limit: 30,
keyword: debouncedKeywords,
...(tagIDs.length ? { tag_ids: tagIDs } : {}),
...(creatorID ? { creator_id: creatorID } : {}),
}), [creatorID, debouncedKeywords, tagIDs])
const {
data,
isLoading,
isFetching,
isFetchingNextPage,
fetchNextPage,
hasNextPage,
error,
refetch,
} = useInfiniteSnippetList(snippetListQuery, {
enabled: !isCurrentWorkspaceDatasetOperator,
})
useEffect(() => {
if (isCurrentWorkspaceDatasetOperator)
return
const hasMore = hasNextPage ?? true
let observer: IntersectionObserver | undefined
if (error) {
if (observer)
observer.disconnect()
return
}
if (anchorRef.current && containerRef.current) {
const containerHeight = containerRef.current.clientHeight
const dynamicMargin = Math.max(100, Math.min(containerHeight * 0.2, 200))
observer = new IntersectionObserver((entries) => {
if (entries[0]!.isIntersecting && !isLoading && !isFetchingNextPage && !error && hasMore)
fetchNextPage()
}, {
root: containerRef.current,
rootMargin: `${dynamicMargin}px`,
threshold: 0.1,
})
observer.observe(anchorRef.current)
}
return () => observer?.disconnect()
}, [error, fetchNextPage, hasNextPage, isCurrentWorkspaceDatasetOperator, isFetchingNextPage, isLoading])
const handleCreatorsChange = useCallback((creatorIDs: string[]) => {
setCreatorID(creatorIDs.at(-1) ?? '')
}, [setCreatorID])
const pages = useMemo(() => data?.pages ?? [], [data?.pages])
const snippets = useMemo<SnippetListItem[]>(() => pages.flatMap(({ data: pageSnippets }) => pageSnippets), [pages])
const hasAnySnippet = (pages[0]?.total ?? 0) > 0
const showSkeleton = isLoading || (isFetching && pages.length === 0)
return (
<div ref={containerRef} className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
<div className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-x-4 gap-y-2 bg-background-body px-12 pt-7 pb-5">
<div className="flex flex-wrap items-center gap-2">
<CreatorsFilter
value={creatorID ? [creatorID] : []}
onChange={handleCreatorsChange}
/>
<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" />
<Input
className={cn('pl-6.5', keywords && 'pr-6.5')}
value={keywords}
onChange={e => setKeywords(e.target.value)}
placeholder={t('tabs.searchSnippets', { ns: 'workflow' })}
/>
{!!keywords && (
<button
type="button"
aria-label={t('operation.clear', { ns: 'common' })}
className="absolute top-1/2 right-2 flex size-4 -translate-y-1/2 items-center justify-center text-components-input-text-placeholder hover:text-components-input-text-filled"
onClick={() => setKeywords('')}
>
<span aria-hidden className="i-ri-close-circle-fill size-4" />
</button>
)}
</div>
</div>
{(isCurrentWorkspaceEditor || isLoadingCurrentWorkspace) && (
<SnippetCreateButton />
)}
</div>
<div className={cn(
'relative grid grow grid-cols-1 content-start gap-4 px-12 pt-2 2k:grid-cols-6 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5',
!hasAnySnippet && 'overflow-hidden',
)}
>
{showSkeleton
? <SnippetCardSkeleton count={6} />
: hasAnySnippet
? snippets.map(snippet => (
<SnippetCard key={snippet.id} snippet={snippet} />
))
: <Empty message={t('tabs.noSnippetsFound', { ns: 'workflow' })} />}
{isFetchingNextPage && (
<SnippetCardSkeleton count={3} />
)}
</div>
{!systemFeatures.branding.enabled && (
<Footer />
)}
<div ref={anchorRef} className="h-0"> </div>
<TagManagementModal
type="snippet"
show={showTagManagementModal}
onClose={() => setShowTagManagementModal(false)}
onTagsChange={refetch}
/>
</div>
)
}
export default SnippetList

View File

@@ -1,107 +0,0 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import SnippetCreateCard from '../snippet-create-card'
const { mockPush, mockCreateMutate, mockToastSuccess, mockToastError } = vi.hoisted(() => ({
mockPush: vi.fn(),
mockCreateMutate: vi.fn(),
mockToastSuccess: vi.fn(),
mockToastError: vi.fn(),
}))
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: mockPush,
}),
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
success: mockToastSuccess,
error: mockToastError,
},
}))
vi.mock('@/service/use-snippets', () => ({
useCreateSnippetMutation: () => ({
mutate: mockCreateMutate,
isPending: false,
}),
}))
vi.mock('../snippet-import-dsl-dialog', () => ({
default: ({ show, onClose, onSuccess }: { show: boolean, onClose: () => void, onSuccess?: (snippetId: string) => void }) => {
if (!show)
return null
return (
<div data-testid="snippet-import-dsl-dialog">
<button type="button" onClick={() => onSuccess?.('snippet-imported')}>Complete Import</button>
<button type="button" onClick={onClose}>Close Import</button>
</div>
)
},
}))
describe('SnippetCreateCard', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Create From Blank', () => {
it('should open the create dialog and create a snippet from the modal', async () => {
mockCreateMutate.mockImplementation((_payload, options?: { onSuccess?: (snippet: { id: string }) => void }) => {
options?.onSuccess?.({ id: 'snippet-123' })
})
render(<SnippetCreateCard />)
fireEvent.click(screen.getByRole('button', { name: 'snippet.createFromBlank' }))
expect(screen.getByText('workflow.snippet.createDialogTitle')).toBeInTheDocument()
fireEvent.change(screen.getByPlaceholderText('workflow.snippet.namePlaceholder'), {
target: { value: 'My Snippet' },
})
fireEvent.change(screen.getByPlaceholderText('workflow.snippet.descriptionPlaceholder'), {
target: { value: 'Useful snippet description' },
})
fireEvent.click(screen.getByRole('button', { name: /workflow\.snippet\.confirm/i }))
expect(mockCreateMutate).toHaveBeenCalledWith({
body: {
name: 'My Snippet',
description: 'Useful snippet description',
icon_info: {
icon: '🤖',
icon_type: 'emoji',
icon_background: '#FFEAD5',
icon_url: undefined,
},
},
}, expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}))
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith('/snippets/snippet-123/orchestrate')
})
expect(mockToastSuccess).toHaveBeenCalledWith('workflow.snippet.createSuccess')
})
})
describe('Import DSL', () => {
it('should open the import dialog and navigate when the import succeeds', async () => {
render(<SnippetCreateCard />)
fireEvent.click(screen.getByRole('button', { name: 'app.importDSL' }))
expect(screen.getByTestId('snippet-import-dsl-dialog')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Complete Import' }))
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith('/snippets/snippet-imported/orchestrate')
})
})
})
})

View File

@@ -1,109 +0,0 @@
'use client'
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
import { toast } from '@langgenius/dify-ui/toast'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import CreateSnippetDialog from '@/app/components/workflow/create-snippet-dialog'
import { useRouter } from '@/next/navigation'
import {
useCreateSnippetMutation,
} from '@/service/use-snippets'
import SnippetImportDSLDialog from './snippet-import-dsl-dialog'
const SnippetCreateCard = () => {
const { t } = useTranslation('snippet')
const { push } = useRouter()
const createSnippetMutation = useCreateSnippetMutation()
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
const [isImportDSLDialogOpen, setIsImportDSLDialogOpen] = useState(false)
const handleCreateFromBlank = () => {
setIsCreateDialogOpen(true)
}
const handleImportDSL = () => {
setIsImportDSLDialogOpen(true)
}
const handleCreateSnippet = ({
name,
description,
icon,
}: {
name: string
description: string
icon: AppIconSelection
}) => {
createSnippetMutation.mutate({
body: {
name,
description: description || undefined,
icon_info: {
icon: icon.type === 'emoji' ? icon.icon : icon.fileId,
icon_type: icon.type,
icon_background: icon.type === 'emoji' ? icon.background : undefined,
icon_url: icon.type === 'image' ? icon.url : undefined,
},
},
}, {
onSuccess: (snippet) => {
toast.success(t('snippet.createSuccess', { ns: 'workflow' }))
setIsCreateDialogOpen(false)
push(`/snippets/${snippet.id}/orchestrate`)
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : t('createFailed'))
},
})
}
return (
<>
<div className="relative col-span-1 inline-flex h-[160px] flex-col justify-between rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg transition-opacity">
<div className="grow rounded-t-xl p-2">
<div className="px-6 pt-2 pb-1 text-xs leading-[18px] font-medium text-text-tertiary">{t('create')}</div>
<button
type="button"
className="mb-1 flex w-full cursor-pointer items-center rounded-lg px-6 py-[7px] text-[13px] leading-[18px] font-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary disabled:cursor-not-allowed disabled:opacity-50"
disabled={createSnippetMutation.isPending}
onClick={handleCreateFromBlank}
>
<span aria-hidden className="mr-2 i-ri-sticky-note-add-line h-4 w-4 shrink-0" />
{t('createFromBlank')}
</button>
<button
type="button"
className="flex w-full cursor-pointer items-center rounded-lg px-6 py-[7px] text-[13px] leading-[18px] font-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
onClick={handleImportDSL}
>
<span aria-hidden className="mr-2 i-ri-file-upload-line h-4 w-4 shrink-0" />
{t('importDSL', { ns: 'app' })}
</button>
</div>
</div>
{isCreateDialogOpen && (
<CreateSnippetDialog
isOpen={isCreateDialogOpen}
isSubmitting={createSnippetMutation.isPending}
onClose={() => setIsCreateDialogOpen(false)}
onConfirm={handleCreateSnippet}
/>
)}
{isImportDSLDialogOpen && (
<SnippetImportDSLDialog
show={isImportDSLDialogOpen}
onClose={() => setIsImportDSLDialogOpen(false)}
onSuccess={(snippetId) => {
setIsImportDSLDialogOpen(false)
push(`/snippets/${snippetId}/orchestrate`)
}}
/>
)}
</>
)
}
export default SnippetCreateCard

View File

@@ -1,265 +0,0 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { useDebounceFn, useKeyPress } from 'ahooks'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Uploader from '@/app/components/app/create-from-dsl-modal/uploader'
import Input from '@/app/components/base/input'
import {
DSLImportMode,
DSLImportStatus,
} from '@/models/app'
import {
useConfirmSnippetImportMutation,
useImportSnippetDSLMutation,
} from '@/service/use-snippets'
import ShortcutsName from '../../workflow/shortcuts-name'
type SnippetImportDSLDialogProps = {
show: boolean
onClose: () => void
onSuccess?: (snippetId: string) => void
}
const SnippetImportDSLTab = {
FromFile: 'from-file',
FromURL: 'from-url',
} as const
type SnippetImportDSLTabValue = typeof SnippetImportDSLTab[keyof typeof SnippetImportDSLTab]
const SnippetImportDSLDialog = ({
show,
onClose,
onSuccess,
}: SnippetImportDSLDialogProps) => {
const { t } = useTranslation()
const importSnippetDSLMutation = useImportSnippetDSLMutation()
const confirmSnippetImportMutation = useConfirmSnippetImportMutation()
const [currentFile, setCurrentFile] = useState<File>()
const [fileContent, setFileContent] = useState<string>()
const [currentTab, setCurrentTab] = useState<SnippetImportDSLTabValue>(SnippetImportDSLTab.FromFile)
const [dslUrlValue, setDslUrlValue] = useState('')
const [showVersionMismatchDialog, setShowVersionMismatchDialog] = useState(false)
const [versions, setVersions] = useState<{ importedVersion: string, systemVersion: string }>()
const [importId, setImportId] = useState<string>()
const isImporting = importSnippetDSLMutation.isPending || confirmSnippetImportMutation.isPending
const readFile = (file: File) => {
const reader = new FileReader()
reader.onload = (event) => {
const content = event.target?.result
setFileContent(content as string)
}
reader.readAsText(file)
}
const handleFile = (file?: File) => {
setCurrentFile(file)
if (file)
readFile(file)
if (!file)
setFileContent('')
}
const completeImport = (snippetId?: string, status: string = DSLImportStatus.COMPLETED) => {
if (!snippetId) {
toast.error(t('importFailed', { ns: 'snippet' }))
return
}
if (status === DSLImportStatus.COMPLETED_WITH_WARNINGS)
toast.warning(t('newApp.appCreateDSLWarning', { ns: 'app' }))
else
toast.success(t('importSuccess', { ns: 'snippet' }))
onSuccess?.(snippetId)
}
const handleImportResponse = (response: {
id: string
status: string
snippet_id?: string
imported_dsl_version?: string
current_dsl_version?: string
}) => {
if (response.status === DSLImportStatus.COMPLETED || response.status === DSLImportStatus.COMPLETED_WITH_WARNINGS) {
completeImport(response.snippet_id, response.status)
return
}
if (response.status === DSLImportStatus.PENDING) {
setVersions({
importedVersion: response.imported_dsl_version ?? '',
systemVersion: response.current_dsl_version ?? '',
})
setImportId(response.id)
setShowVersionMismatchDialog(true)
return
}
toast.error(t('importFailed', { ns: 'snippet' }))
}
const handleCreate = () => {
if (currentTab === SnippetImportDSLTab.FromFile && !currentFile)
return
if (currentTab === SnippetImportDSLTab.FromURL && !dslUrlValue)
return
importSnippetDSLMutation.mutate({
mode: currentTab === SnippetImportDSLTab.FromFile ? DSLImportMode.YAML_CONTENT : DSLImportMode.YAML_URL,
yamlContent: currentTab === SnippetImportDSLTab.FromFile ? fileContent || '' : undefined,
yamlUrl: currentTab === SnippetImportDSLTab.FromURL ? dslUrlValue : undefined,
}, {
onSuccess: handleImportResponse,
onError: (error) => {
toast.error(error instanceof Error ? error.message : t('importFailed', { ns: 'snippet' }))
},
})
}
const { run: handleCreateSnippet } = useDebounceFn(handleCreate, { wait: 300 })
const handleConfirmImport = () => {
if (!importId)
return
confirmSnippetImportMutation.mutate({
importId,
}, {
onSuccess: (response) => {
setShowVersionMismatchDialog(false)
completeImport(response.snippet_id)
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : t('importFailed', { ns: 'snippet' }))
},
})
}
useKeyPress(['meta.enter', 'ctrl.enter'], () => {
if (!show || showVersionMismatchDialog || isImporting)
return
if ((currentTab === SnippetImportDSLTab.FromFile && currentFile) || (currentTab === SnippetImportDSLTab.FromURL && dslUrlValue))
handleCreateSnippet()
})
const buttonDisabled = useMemo(() => {
if (isImporting)
return true
if (currentTab === SnippetImportDSLTab.FromFile)
return !currentFile
return !dslUrlValue
}, [currentFile, currentTab, dslUrlValue, isImporting])
return (
<>
<Dialog open={show} onOpenChange={open => !open && onClose()}>
<DialogContent className="w-[520px] p-0">
<div className="flex items-center justify-between pt-6 pr-5 pb-3 pl-6">
<DialogTitle className="title-2xl-semi-bold text-text-primary">
{t('importFromDSL', { ns: 'app' })}
</DialogTitle>
<DialogCloseButton className="top-6 right-5 h-8 w-8" />
</div>
<div className="flex h-9 items-center space-x-6 border-b border-divider-subtle px-6 system-md-semibold text-text-tertiary">
{[
{ key: SnippetImportDSLTab.FromFile, label: t('importFromDSLFile', { ns: 'app' }) },
{ key: SnippetImportDSLTab.FromURL, label: t('importFromDSLUrl', { ns: 'app' }) },
].map(tab => (
<button
key={tab.key}
type="button"
className={cn(
'relative flex h-full cursor-pointer items-center',
currentTab === tab.key && 'text-text-primary',
)}
onClick={() => setCurrentTab(tab.key)}
>
{tab.label}
{currentTab === tab.key && (
<div className="absolute bottom-0 h-[2px] w-full bg-util-colors-blue-brand-blue-brand-600" />
)}
</button>
))}
</div>
<div className="px-6 py-4">
{currentTab === SnippetImportDSLTab.FromFile && (
<Uploader
className="mt-0"
file={currentFile}
updateFile={handleFile}
/>
)}
{currentTab === SnippetImportDSLTab.FromURL && (
<div>
<div className="mb-1 system-md-semibold text-text-secondary">DSL URL</div>
<Input
placeholder={t('importFromDSLUrlPlaceholder', { ns: 'app' }) || ''}
value={dslUrlValue}
onChange={e => setDslUrlValue(e.target.value)}
/>
</div>
)}
</div>
<div className="flex justify-end px-6 py-5">
<Button className="mr-2" disabled={isImporting} onClick={onClose}>
{t('newApp.Cancel', { ns: 'app' })}
</Button>
<Button
disabled={buttonDisabled}
variant="primary"
onClick={handleCreateSnippet}
className="gap-1"
>
<span>{t('newApp.Create', { ns: 'app' })}</span>
<ShortcutsName keys={['ctrl', '↵']} bgColor="white" />
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={showVersionMismatchDialog} onOpenChange={open => !open && setShowVersionMismatchDialog(false)}>
<DialogContent className="w-[480px]">
<div className="flex flex-col items-start gap-2 self-stretch pb-4">
<DialogTitle className="title-2xl-semi-bold text-text-primary">
{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}
</DialogTitle>
<div className="flex grow flex-col system-md-regular text-text-secondary">
<div>{t('newApp.appCreateDSLErrorPart1', { ns: 'app' })}</div>
<div>{t('newApp.appCreateDSLErrorPart2', { ns: 'app' })}</div>
<br />
<div>
{t('newApp.appCreateDSLErrorPart3', { ns: 'app' })}
<span className="system-md-medium">{versions?.importedVersion}</span>
</div>
<div>
{t('newApp.appCreateDSLErrorPart4', { ns: 'app' })}
<span className="system-md-medium">{versions?.systemVersion}</span>
</div>
</div>
</div>
<div className="flex items-start justify-end gap-2 self-stretch pt-6">
<Button variant="secondary" disabled={isImporting} onClick={() => setShowVersionMismatchDialog(false)}>
{t('newApp.Cancel', { ns: 'app' })}
</Button>
<Button variant="primary" tone="destructive" disabled={isImporting} onClick={handleConfirmImport}>
{t('newApp.Confirm', { ns: 'app' })}
</Button>
</div>
</DialogContent>
</Dialog>
</>
)
}
export default SnippetImportDSLDialog

View File

@@ -32,6 +32,7 @@ export const listCustomizedSnippetsContract = base
page: number
limit: number
keyword?: string
tag_ids?: string[]
creator_id?: string
is_published?: boolean
}

View File

@@ -1,7 +1,7 @@
import { type } from '@orpc/contract'
import { base } from '../base'
export type TagType = 'knowledge' | 'app'
export type TagType = 'knowledge' | 'app' | 'snippet'
export type Tag = {
id: string

View File

@@ -82,7 +82,7 @@ export const TagFilter = ({
<span className="p-px">
<Tag01Icon className="size-3.5 text-text-tertiary" aria-hidden="true" />
</span>
<span className="min-w-0 grow truncate text-[13px] leading-4.5 text-text-secondary">
<span className="min-w-0 grow truncate text-[13px] leading-4.5 text-text-tertiary">
{!value.length && t('tag.placeholder', { ns: 'common' })}
{!!value.length && currentTagName}
</span>

View File

@@ -1,4 +1,5 @@
'use client'
import type { TagType } from '@/contract/console/tags'
import { Dialog, DialogCloseButton, DialogContent } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation, useQuery } from '@tanstack/react-query'
@@ -8,7 +9,7 @@ import { consoleQuery } from '@/service/client'
import { TagItemEditor } from './tag-item-editor'
type TagManagementModalProps = {
type: 'knowledge' | 'app'
type: TagType
show: boolean
onClose: () => void
onTagsChange?: () => void

View File

@@ -25,6 +25,7 @@ type SnippetListParams = {
page?: number
limit?: number
keyword?: string
tag_ids?: string[]
creator_id?: string
is_published?: boolean
}
@@ -124,6 +125,7 @@ const normalizeSnippetListParams = (params: SnippetListParams) => {
page: params.page ?? DEFAULT_SNIPPET_LIST_PARAMS.page,
limit: params.limit ?? DEFAULT_SNIPPET_LIST_PARAMS.limit,
...(params.keyword ? { keyword: params.keyword } : {}),
...(params.tag_ids?.length ? { tag_ids: params.tag_ids } : {}),
...(params.creator_id ? { creator_id: params.creator_id } : {}),
...(typeof params.is_published === 'boolean' ? { is_published: params.is_published } : {}),
}