diff --git a/web/app/(commonLayout)/snippets/page.tsx b/web/app/(commonLayout)/snippets/page.tsx index 3a09b6f617..a7f3bcc5dc 100644 --- a/web/app/(commonLayout)/snippets/page.tsx +++ b/web/app/(commonLayout)/snippets/page.tsx @@ -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 ( - + ) } diff --git a/web/app/components/snippet-list/__tests__/index.spec.tsx b/web/app/components/snippet-list/__tests__/index.spec.tsx new file mode 100644 index 0000000000..8676fca2bb --- /dev/null +++ b/web/app/components/snippet-list/__tests__/index.spec.tsx @@ -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( + + + , + ) +} + +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() + }) +}) diff --git a/web/app/components/snippets/components/__tests__/snippet-card.spec.tsx b/web/app/components/snippet-list/components/__tests__/snippet-card.spec.tsx similarity index 100% rename from web/app/components/snippets/components/__tests__/snippet-card.spec.tsx rename to web/app/components/snippet-list/components/__tests__/snippet-card.spec.tsx diff --git a/web/app/components/snippet-list/components/__tests__/snippet-create-button.spec.tsx b/web/app/components/snippet-list/components/__tests__/snippet-create-button.spec.tsx new file mode 100644 index 0000000000..624bf8cf7e --- /dev/null +++ b/web/app/components/snippet-list/components/__tests__/snippet-create-button.spec.tsx @@ -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() + + 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') + }) +}) diff --git a/web/app/components/snippets/components/snippet-card.tsx b/web/app/components/snippet-list/components/snippet-card.tsx similarity index 100% rename from web/app/components/snippets/components/snippet-card.tsx rename to web/app/components/snippet-list/components/snippet-card.tsx diff --git a/web/app/components/snippet-list/components/snippet-create-button.tsx b/web/app/components/snippet-list/components/snippet-create-button.tsx new file mode 100644 index 0000000000..4a94254116 --- /dev/null +++ b/web/app/components/snippet-list/components/snippet-create-button.tsx @@ -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 ( + <> + + + {isCreateDialogOpen && ( + setIsCreateDialogOpen(false)} + onConfirm={handleCreateSnippet} + /> + )} + + ) +} + +export default SnippetCreateButton diff --git a/web/app/components/snippet-list/constants.ts b/web/app/components/snippet-list/constants.ts new file mode 100644 index 0000000000..287d7e231f --- /dev/null +++ b/web/app/components/snippet-list/constants.ts @@ -0,0 +1 @@ +export const SNIPPET_LIST_SEARCH_DEBOUNCE_MS = 500 diff --git a/web/app/components/snippet-list/hooks/use-snippets-query-state.ts b/web/app/components/snippet-list/hooks/use-snippets-query-state.ts new file mode 100644 index 0000000000..c0e4ed89e1 --- /dev/null +++ b/web/app/components/snippet-list/hooks/use-snippets-query-state.ts @@ -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]) +} diff --git a/web/app/components/snippet-list/index.tsx b/web/app/components/snippet-list/index.tsx new file mode 100644 index 0000000000..6b5ed0a99d --- /dev/null +++ b/web/app/components/snippet-list/index.tsx @@ -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 => ( +
+ ))} + + ) +} + +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(null) + const anchorRef = useRef(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(() => pages.flatMap(({ data: pageSnippets }) => pageSnippets), [pages]) + const hasAnySnippet = (pages[0]?.total ?? 0) > 0 + const showSkeleton = isLoading || (isFetching && pages.length === 0) + + return ( +
+
+
+ + setShowTagManagementModal(true)} /> +
+ + setKeywords(e.target.value)} + placeholder={t('tabs.searchSnippets', { ns: 'workflow' })} + /> + {!!keywords && ( + + )} +
+
+ {(isCurrentWorkspaceEditor || isLoadingCurrentWorkspace) && ( + + )} +
+
+ {showSkeleton + ? + : hasAnySnippet + ? snippets.map(snippet => ( + + )) + : } + {isFetchingNextPage && ( + + )} +
+ {!systemFeatures.branding.enabled && ( +
+ )} +
+ setShowTagManagementModal(false)} + onTagsChange={refetch} + /> +
+ ) +} + +export default SnippetList diff --git a/web/app/components/snippets/components/__tests__/snippet-create-card.spec.tsx b/web/app/components/snippets/components/__tests__/snippet-create-card.spec.tsx deleted file mode 100644 index da8135ea3e..0000000000 --- a/web/app/components/snippets/components/__tests__/snippet-create-card.spec.tsx +++ /dev/null @@ -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 ( -
- - -
- ) - }, -})) - -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() - - 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() - - 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') - }) - }) - }) -}) diff --git a/web/app/components/snippets/components/snippet-create-card.tsx b/web/app/components/snippets/components/snippet-create-card.tsx deleted file mode 100644 index cea7ebcafb..0000000000 --- a/web/app/components/snippets/components/snippet-create-card.tsx +++ /dev/null @@ -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 ( - <> -
-
-
{t('create')}
- - -
-
- - {isCreateDialogOpen && ( - setIsCreateDialogOpen(false)} - onConfirm={handleCreateSnippet} - /> - )} - - {isImportDSLDialogOpen && ( - setIsImportDSLDialogOpen(false)} - onSuccess={(snippetId) => { - setIsImportDSLDialogOpen(false) - push(`/snippets/${snippetId}/orchestrate`) - }} - /> - )} - - ) -} - -export default SnippetCreateCard diff --git a/web/app/components/snippets/components/snippet-import-dsl-dialog.tsx b/web/app/components/snippets/components/snippet-import-dsl-dialog.tsx deleted file mode 100644 index be23700e62..0000000000 --- a/web/app/components/snippets/components/snippet-import-dsl-dialog.tsx +++ /dev/null @@ -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() - const [fileContent, setFileContent] = useState() - const [currentTab, setCurrentTab] = useState(SnippetImportDSLTab.FromFile) - const [dslUrlValue, setDslUrlValue] = useState('') - const [showVersionMismatchDialog, setShowVersionMismatchDialog] = useState(false) - const [versions, setVersions] = useState<{ importedVersion: string, systemVersion: string }>() - const [importId, setImportId] = useState() - - 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 ( - <> - !open && onClose()}> - -
- - {t('importFromDSL', { ns: 'app' })} - - -
- -
- {[ - { key: SnippetImportDSLTab.FromFile, label: t('importFromDSLFile', { ns: 'app' }) }, - { key: SnippetImportDSLTab.FromURL, label: t('importFromDSLUrl', { ns: 'app' }) }, - ].map(tab => ( - - ))} -
- -
- {currentTab === SnippetImportDSLTab.FromFile && ( - - )} - {currentTab === SnippetImportDSLTab.FromURL && ( -
-
DSL URL
- setDslUrlValue(e.target.value)} - /> -
- )} -
- -
- - -
-
-
- - !open && setShowVersionMismatchDialog(false)}> - -
- - {t('newApp.appCreateDSLErrorTitle', { ns: 'app' })} - -
-
{t('newApp.appCreateDSLErrorPart1', { ns: 'app' })}
-
{t('newApp.appCreateDSLErrorPart2', { ns: 'app' })}
-
-
- {t('newApp.appCreateDSLErrorPart3', { ns: 'app' })} - {versions?.importedVersion} -
-
- {t('newApp.appCreateDSLErrorPart4', { ns: 'app' })} - {versions?.systemVersion} -
-
-
-
- - -
-
-
- - ) -} - -export default SnippetImportDSLDialog diff --git a/web/contract/console/snippets.ts b/web/contract/console/snippets.ts index 7fd096aa3a..ea7974b917 100644 --- a/web/contract/console/snippets.ts +++ b/web/contract/console/snippets.ts @@ -32,6 +32,7 @@ export const listCustomizedSnippetsContract = base page: number limit: number keyword?: string + tag_ids?: string[] creator_id?: string is_published?: boolean } diff --git a/web/contract/console/tags.ts b/web/contract/console/tags.ts index 438da8a95e..417ec2f9f0 100644 --- a/web/contract/console/tags.ts +++ b/web/contract/console/tags.ts @@ -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 diff --git a/web/features/tag-management/components/tag-filter.tsx b/web/features/tag-management/components/tag-filter.tsx index 4402d118c6..00cb75b62f 100644 --- a/web/features/tag-management/components/tag-filter.tsx +++ b/web/features/tag-management/components/tag-filter.tsx @@ -82,7 +82,7 @@ export const TagFilter = ({ - + {!value.length && t('tag.placeholder', { ns: 'common' })} {!!value.length && currentTagName} diff --git a/web/features/tag-management/components/tag-management-modal.tsx b/web/features/tag-management/components/tag-management-modal.tsx index 2188fb5ec6..49d6b540d4 100644 --- a/web/features/tag-management/components/tag-management-modal.tsx +++ b/web/features/tag-management/components/tag-management-modal.tsx @@ -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 diff --git a/web/service/use-snippets.ts b/web/service/use-snippets.ts index 85f0cde045..f08be78ddc 100644 --- a/web/service/use-snippets.ts +++ b/web/service/use-snippets.ts @@ -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 } : {}), }