feat(web): create snippets by DSL import

This commit is contained in:
JzoNg 2026-03-23 14:55:36 +08:00
parent feef2dd1fa
commit f782ac6b3c
7 changed files with 383 additions and 9 deletions

View File

@ -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 (
<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()
@ -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(<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

@ -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 = () => {
<span aria-hidden className="i-ri-sticky-note-add-line mr-2 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] font-medium leading-[18px] text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary">
<button
type="button"
className="flex w-full cursor-pointer items-center rounded-lg px-6 py-[7px] text-[13px] font-medium leading-[18px] text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
onClick={handleImportDSL}
>
<span aria-hidden className="i-ri-file-upload-line mr-2 h-4 w-4 shrink-0" />
{t('importDSL', { ns: 'app' })}
</button>
@ -80,6 +92,17 @@ const SnippetCreateCard = () => {
onConfirm={handleCreateSnippet}
/>
)}
{isImportDSLDialogOpen && (
<SnippetImportDSLDialog
show={isImportDSLDialogOpen}
onClose={() => setIsImportDSLDialogOpen(false)}
onSuccess={(snippetId) => {
setIsImportDSLDialogOpen(false)
push(`/snippets/${snippetId}/orchestrate`)
}}
/>
)}
</>
)
}

View File

@ -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<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 pb-3 pl-6 pr-5 pt-6">
<DialogTitle className="text-text-primary title-2xl-semi-bold">
{t('importFromDSL', { ns: 'app' })}
</DialogTitle>
<DialogCloseButton className="right-5 top-6 h-8 w-8" />
</div>
<div className="flex h-9 items-center space-x-6 border-b border-divider-subtle px-6 text-text-tertiary system-md-semibold">
{[
{ 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 text-text-secondary system-md-semibold">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="text-text-primary title-2xl-semi-bold">
{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}
</DialogTitle>
<div className="flex grow flex-col text-text-secondary system-md-regular">
<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" destructive disabled={isImporting} onClick={handleConfirmImport}>
{t('newApp.Confirm', { ns: 'app' })}
</Button>
</div>
</DialogContent>
</Dialog>
</>
)
}
export default SnippetImportDSLDialog

View File

@ -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",

View File

@ -3,6 +3,8 @@
"createFailed": "创建 Snippet 失败",
"createFromBlank": "创建空白 Snippet",
"defaultName": "未命名 Snippet",
"importFailed": "导入 Snippet DSL 失败",
"importSuccess": "Snippet 导入成功",
"inputFieldButton": "输入字段",
"notFoundDescription": "未找到对应的 snippet 静态数据。",
"notFoundTitle": "未找到 Snippet",

View File

@ -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<SnippetDSLImportResponse, Error, { mode: 'yaml-content' | 'yaml-url', yamlContent?: string, yamlUrl?: string }>({
mutationFn: ({ mode, yamlContent, yamlUrl }) => {
return consoleClient.snippets.import({
body: {
mode,
yaml_content: yamlContent,
yaml_url: yamlUrl,
},
}) as Promise<SnippetDSLImportResponse>
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: consoleQuery.snippets.key(),
})
},
})
}
export const useConfirmSnippetImportMutation = () => {
const queryClient = useQueryClient()
return useMutation<SnippetDSLImportResponse, Error, { importId: string }>({
mutationFn: ({ importId }) => {
return consoleClient.snippets.confirmImport({
params: {
importId,
},
}) as Promise<SnippetDSLImportResponse>
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: consoleQuery.snippets.key(),
})
},
})
}
export type {
CreateSnippetPayload,
SnippetDSLImportResponse,
SnippetListResponse,
UpdateSnippetPayload,
}

View File

@ -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