mirror of https://github.com/langgenius/dify.git
feat(web): create snippets by DSL import
This commit is contained in:
parent
feef2dd1fa
commit
f782ac6b3c
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
"createFailed": "创建 Snippet 失败",
|
||||
"createFromBlank": "创建空白 Snippet",
|
||||
"defaultName": "未命名 Snippet",
|
||||
"importFailed": "导入 Snippet DSL 失败",
|
||||
"importSuccess": "Snippet 导入成功",
|
||||
"inputFieldButton": "输入字段",
|
||||
"notFoundDescription": "未找到对应的 snippet 静态数据。",
|
||||
"notFoundTitle": "未找到 Snippet",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue