mirror of
https://github.com/langgenius/dify.git
synced 2026-05-26 22:00:53 -04:00
refactor(web): snippet list page
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
259
web/app/components/snippet-list/__tests__/index.spec.tsx
Normal file
259
web/app/components/snippet-list/__tests__/index.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
1
web/app/components/snippet-list/constants.ts
Normal file
1
web/app/components/snippet-list/constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const SNIPPET_LIST_SEARCH_DEBOUNCE_MS = 500
|
||||
@@ -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])
|
||||
}
|
||||
189
web/app/components/snippet-list/index.tsx
Normal file
189
web/app/components/snippet-list/index.tsx
Normal 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
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -32,6 +32,7 @@ export const listCustomizedSnippetsContract = base
|
||||
page: number
|
||||
limit: number
|
||||
keyword?: string
|
||||
tag_ids?: string[]
|
||||
creator_id?: string
|
||||
is_published?: boolean
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 } : {}),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user