mirror of https://github.com/langgenius/dify.git
feat(web): add snippet creation dialog flow
This commit is contained in:
parent
a716d8789d
commit
feef2dd1fa
|
|
@ -0,0 +1,77 @@
|
|||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import SnippetCreateCard from '../snippet-create-card'
|
||||
|
||||
const { mockPush, mockMutate, mockToastSuccess } = vi.hoisted(() => ({
|
||||
mockPush: vi.fn(),
|
||||
mockMutate: vi.fn(),
|
||||
mockToastSuccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/toast', () => ({
|
||||
toast: {
|
||||
success: mockToastSuccess,
|
||||
error: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-snippets', () => ({
|
||||
useCreateSnippetMutation: () => ({
|
||||
mutate: mockMutate,
|
||||
isPending: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('SnippetCreateCard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
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 }) => {
|
||||
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(mockMutate).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.createSuccess')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,24 +1,86 @@
|
|||
'use client'
|
||||
|
||||
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
|
||||
import { useState } from 'react'
|
||||
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'
|
||||
|
||||
const SnippetCreateCard = () => {
|
||||
const { t } = useTranslation('snippet')
|
||||
const { push } = useRouter()
|
||||
const createSnippetMutation = useCreateSnippetMutation()
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
|
||||
|
||||
const handleCreateFromBlank = () => {
|
||||
setIsCreateDialogOpen(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 pb-1 pt-2 text-xs font-medium leading-[18px] text-text-tertiary">{t('create')}</div>
|
||||
<div className="mb-1 flex w-full items-center rounded-lg px-6 py-[7px] text-[13px] font-medium leading-[18px] text-text-tertiary">
|
||||
<span aria-hidden className="i-ri-sticky-note-add-line mr-2 h-4 w-4 shrink-0" />
|
||||
{t('newApp.startFromBlank', { ns: 'app' })}
|
||||
</div>
|
||||
<div className="flex w-full items-center rounded-lg px-6 py-[7px] text-[13px] font-medium leading-[18px] text-text-tertiary">
|
||||
<span aria-hidden className="i-ri-file-upload-line mr-2 h-4 w-4 shrink-0" />
|
||||
{t('importDSL', { ns: 'app' })}
|
||||
<>
|
||||
<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 pb-1 pt-2 text-xs font-medium leading-[18px] 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] font-medium leading-[18px] 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="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">
|
||||
<span aria-hidden className="i-ri-file-upload-line mr-2 h-4 w-4 shrink-0" />
|
||||
{t('importDSL', { ns: 'app' })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isCreateDialogOpen && (
|
||||
<CreateSnippetDialog
|
||||
isOpen={isCreateDialogOpen}
|
||||
selectedNodeIds={[]}
|
||||
isSubmitting={createSnippetMutation.isPending}
|
||||
onClose={() => setIsCreateDialogOpen(false)}
|
||||
onConfirm={handleCreateSnippet}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import AppIconPicker from '@/app/components/base/app-icon-picker'
|
|||
import Button from '@/app/components/base/button'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { Dialog, DialogCloseButton, DialogContent, DialogPortal, DialogTitle } from '@/app/components/base/ui/dialog'
|
||||
import ShortcutsName from './shortcuts-name'
|
||||
|
||||
|
|
@ -26,6 +25,7 @@ type CreateSnippetDialogProps = {
|
|||
selectedNodeIds: string[]
|
||||
onClose: () => void
|
||||
onConfirm: (payload: CreateSnippetDialogPayload) => void
|
||||
isSubmitting?: boolean
|
||||
}
|
||||
|
||||
const defaultIcon: AppIconSelection = {
|
||||
|
|
@ -39,6 +39,7 @@ const CreateSnippetDialog: FC<CreateSnippetDialogProps> = ({
|
|||
selectedNodeIds,
|
||||
onClose,
|
||||
onConfirm,
|
||||
isSubmitting = false,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [name, setName] = useState('')
|
||||
|
|
@ -73,17 +74,15 @@ const CreateSnippetDialog: FC<CreateSnippetDialogProps> = ({
|
|||
}
|
||||
|
||||
onConfirm(payload)
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('snippet.createSuccess', { ns: 'workflow' }),
|
||||
})
|
||||
handleClose()
|
||||
}, [description, handleClose, icon, name, onConfirm, selectedNodeIds, t])
|
||||
}, [description, icon, name, onConfirm, selectedNodeIds])
|
||||
|
||||
useKeyPress(['meta.enter', 'ctrl.enter'], () => {
|
||||
if (!isOpen)
|
||||
return
|
||||
|
||||
if (isSubmitting)
|
||||
return
|
||||
|
||||
handleConfirm()
|
||||
})
|
||||
|
||||
|
|
@ -109,6 +108,7 @@ const CreateSnippetDialog: FC<CreateSnippetDialogProps> = ({
|
|||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder={t('snippet.namePlaceholder', { ns: 'workflow' }) || ''}
|
||||
disabled={isSubmitting}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -133,17 +133,19 @@ const CreateSnippetDialog: FC<CreateSnippetDialogProps> = ({
|
|||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
placeholder={t('snippet.descriptionPlaceholder', { ns: 'workflow' }) || ''}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 px-6 pb-6 pt-5">
|
||||
<Button onClick={handleClose}>
|
||||
<Button disabled={isSubmitting} onClick={handleClose}>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!name.trim()}
|
||||
disabled={!name.trim() || isSubmitting}
|
||||
loading={isSubmitting}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
{t('snippet.confirm', { ns: 'workflow' })}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
{
|
||||
"create": "CREATE SNIPPET",
|
||||
"createFailed": "Failed to create snippet",
|
||||
"createFromBlank": "Create from blank",
|
||||
"defaultName": "Untitled Snippet",
|
||||
"inputFieldButton": "Input Field",
|
||||
"notFoundDescription": "The requested snippet mock was not found.",
|
||||
"notFoundTitle": "Snippet not found",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
{
|
||||
"create": "创建 Snippet",
|
||||
"createFailed": "创建 Snippet 失败",
|
||||
"createFromBlank": "创建空白 Snippet",
|
||||
"defaultName": "未命名 Snippet",
|
||||
"inputFieldButton": "输入字段",
|
||||
"notFoundDescription": "未找到对应的 snippet 静态数据。",
|
||||
"notFoundTitle": "未找到 Snippet",
|
||||
|
|
|
|||
Loading…
Reference in New Issue