From f782ac6b3cacd9806dc1cd857aa96ab551f0bc9f Mon Sep 17 00:00:00 2001 From: JzoNg Date: Mon, 23 Mar 2026 14:55:36 +0800 Subject: [PATCH] feat(web): create snippets by DSL import --- .../__tests__/snippet-create-card.spec.tsx | 44 ++- .../components/snippet-create-card.tsx | 27 +- .../components/snippet-import-dsl-dialog.tsx | 266 ++++++++++++++++++ web/i18n/en-US/snippet.json | 2 + web/i18n/zh-Hans/snippet.json | 2 + web/service/use-snippets.ts | 42 +++ web/types/snippet.ts | 9 + 7 files changed, 383 insertions(+), 9 deletions(-) create mode 100644 web/app/components/snippets/components/snippet-import-dsl-dialog.tsx 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 index f727bf7666..d7d1b2991a 100644 --- a/web/app/components/snippets/components/__tests__/snippet-create-card.spec.tsx +++ b/web/app/components/snippets/components/__tests__/snippet-create-card.spec.tsx @@ -1,10 +1,11 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import SnippetCreateCard from '../snippet-create-card' -const { mockPush, mockMutate, mockToastSuccess } = vi.hoisted(() => ({ +const { mockPush, mockCreateMutate, mockToastSuccess, mockToastError } = vi.hoisted(() => ({ mockPush: vi.fn(), - mockMutate: vi.fn(), + mockCreateMutate: vi.fn(), mockToastSuccess: vi.fn(), + mockToastError: vi.fn(), })) vi.mock('@/next/navigation', () => ({ @@ -16,17 +17,31 @@ vi.mock('@/next/navigation', () => ({ vi.mock('@/app/components/base/ui/toast', () => ({ toast: { success: mockToastSuccess, - error: vi.fn(), + error: mockToastError, }, })) vi.mock('@/service/use-snippets', () => ({ useCreateSnippetMutation: () => ({ - mutate: mockMutate, + 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() @@ -34,7 +49,7 @@ describe('SnippetCreateCard', () => { describe('Create From Blank', () => { it('should open the create dialog and create a snippet from the modal', async () => { - mockMutate.mockImplementation((_payload, options?: { onSuccess?: (snippet: { id: string }) => void }) => { + mockCreateMutate.mockImplementation((_payload, options?: { onSuccess?: (snippet: { id: string }) => void }) => { options?.onSuccess?.({ id: 'snippet-123' }) }) @@ -51,7 +66,7 @@ describe('SnippetCreateCard', () => { }) fireEvent.click(screen.getByRole('button', { name: /workflow\.snippet\.confirm/i })) - expect(mockMutate).toHaveBeenCalledWith({ + expect(mockCreateMutate).toHaveBeenCalledWith({ body: { name: 'My Snippet', description: 'Useful snippet description', @@ -71,7 +86,22 @@ describe('SnippetCreateCard', () => { expect(mockPush).toHaveBeenCalledWith('/snippets/snippet-123/orchestrate') }) - expect(mockToastSuccess).toHaveBeenCalledWith('workflow.createSuccess') + 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 index 75b1f37f78..4ed5b32a35 100644 --- a/web/app/components/snippets/components/snippet-create-card.tsx +++ b/web/app/components/snippets/components/snippet-create-card.tsx @@ -6,18 +6,26 @@ import { useTranslation } from 'react-i18next' import { toast } from '@/app/components/base/ui/toast' import CreateSnippetDialog from '@/app/components/workflow/create-snippet-dialog' import { useRouter } from '@/next/navigation' -import { useCreateSnippetMutation } from '@/service/use-snippets' +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, @@ -64,7 +72,11 @@ const SnippetCreateCard = () => { {t('createFromBlank')} - @@ -80,6 +92,17 @@ const SnippetCreateCard = () => { onConfirm={handleCreateSnippet} /> )} + + {isImportDSLDialogOpen && ( + setIsImportDSLDialogOpen(false)} + onSuccess={(snippetId) => { + setIsImportDSLDialogOpen(false) + push(`/snippets/${snippetId}/orchestrate`) + }} + /> + )} ) } diff --git a/web/app/components/snippets/components/snippet-import-dsl-dialog.tsx b/web/app/components/snippets/components/snippet-import-dsl-dialog.tsx new file mode 100644 index 0000000000..b9c8dc88ad --- /dev/null +++ b/web/app/components/snippets/components/snippet-import-dsl-dialog.tsx @@ -0,0 +1,266 @@ +'use client' + +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 Button from '@/app/components/base/button' +import Input from '@/app/components/base/input' +import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@/app/components/base/ui/dialog' +import { toast } from '@/app/components/base/ui/toast' +import { + DSLImportMode, + DSLImportStatus, +} from '@/models/app' +import { + useConfirmSnippetImportMutation, + useImportSnippetDSLMutation, +} from '@/service/use-snippets' +import { cn } from '@/utils/classnames' +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/i18n/en-US/snippet.json b/web/i18n/en-US/snippet.json index c63190769f..fa060c99c2 100644 --- a/web/i18n/en-US/snippet.json +++ b/web/i18n/en-US/snippet.json @@ -3,6 +3,8 @@ "createFailed": "Failed to create snippet", "createFromBlank": "Create from blank", "defaultName": "Untitled Snippet", + "importFailed": "Failed to import snippet DSL", + "importSuccess": "Snippet imported", "inputFieldButton": "Input Field", "notFoundDescription": "The requested snippet mock was not found.", "notFoundTitle": "Snippet not found", diff --git a/web/i18n/zh-Hans/snippet.json b/web/i18n/zh-Hans/snippet.json index a7f2d2cc1c..db01deb153 100644 --- a/web/i18n/zh-Hans/snippet.json +++ b/web/i18n/zh-Hans/snippet.json @@ -3,6 +3,8 @@ "createFailed": "创建 Snippet 失败", "createFromBlank": "创建空白 Snippet", "defaultName": "未命名 Snippet", + "importFailed": "导入 Snippet DSL 失败", + "importSuccess": "Snippet 导入成功", "inputFieldButton": "输入字段", "notFoundDescription": "未找到对应的 snippet 静态数据。", "notFoundTitle": "未找到 Snippet", diff --git a/web/service/use-snippets.ts b/web/service/use-snippets.ts index 6688fc801f..3acf45f1b0 100644 --- a/web/service/use-snippets.ts +++ b/web/service/use-snippets.ts @@ -8,6 +8,7 @@ import type { import type { CreateSnippetPayload, Snippet as SnippetContract, + SnippetDSLImportResponse, SnippetListResponse, SnippetWorkflow, UpdateSnippetPayload, @@ -266,8 +267,49 @@ export const useIncrementSnippetUseCountMutation = () => { }) } +export const useImportSnippetDSLMutation = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ mode, yamlContent, yamlUrl }) => { + return consoleClient.snippets.import({ + body: { + mode, + yaml_content: yamlContent, + yaml_url: yamlUrl, + }, + }) as Promise + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: consoleQuery.snippets.key(), + }) + }, + }) +} + +export const useConfirmSnippetImportMutation = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ importId }) => { + return consoleClient.snippets.confirmImport({ + params: { + importId, + }, + }) as Promise + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: consoleQuery.snippets.key(), + }) + }, + }) +} + export type { CreateSnippetPayload, + SnippetDSLImportResponse, SnippetListResponse, UpdateSnippetPayload, } diff --git a/web/types/snippet.ts b/web/types/snippet.ts index 76473ab23f..552555db9c 100644 --- a/web/types/snippet.ts +++ b/web/types/snippet.ts @@ -51,6 +51,15 @@ export type SnippetImportPayload = { description?: string } +export type SnippetDSLImportResponse = { + id: string + status: string + snippet_id?: string + current_dsl_version?: string + imported_dsl_version?: string + error: string +} + export type IncrementSnippetUseCountResponse = { result: string use_count: number