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
new file mode 100644
index 0000000000..f727bf7666
--- /dev/null
+++ b/web/app/components/snippets/components/__tests__/snippet-create-card.spec.tsx
@@ -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()
+
+ 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')
+ })
+ })
+})
diff --git a/web/app/components/snippets/components/snippet-create-card.tsx b/web/app/components/snippets/components/snippet-create-card.tsx
index b0a88c3d17..75b1f37f78 100644
--- a/web/app/components/snippets/components/snippet-create-card.tsx
+++ b/web/app/components/snippets/components/snippet-create-card.tsx
@@ -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 (
-
-
-
{t('create')}
-
-
- {t('newApp.startFromBlank', { ns: 'app' })}
-
-
-
- {t('importDSL', { ns: 'app' })}
+ <>
+
+
+
{t('create')}
+
+
-
+
+ {isCreateDialogOpen && (
+
setIsCreateDialogOpen(false)}
+ onConfirm={handleCreateSnippet}
+ />
+ )}
+ >
)
}
diff --git a/web/app/components/workflow/create-snippet-dialog.tsx b/web/app/components/workflow/create-snippet-dialog.tsx
index bb42b33d1f..8c72602668 100644
--- a/web/app/components/workflow/create-snippet-dialog.tsx
+++ b/web/app/components/workflow/create-snippet-dialog.tsx
@@ -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 = ({
selectedNodeIds,
onClose,
onConfirm,
+ isSubmitting = false,
}) => {
const { t } = useTranslation()
const [name, setName] = useState('')
@@ -73,17 +74,15 @@ const CreateSnippetDialog: FC = ({
}
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 = ({
value={name}
onChange={e => setName(e.target.value)}
placeholder={t('snippet.namePlaceholder', { ns: 'workflow' }) || ''}
+ disabled={isSubmitting}
autoFocus
/>
@@ -133,17 +133,19 @@ const CreateSnippetDialog: FC
= ({
value={description}
onChange={e => setDescription(e.target.value)}
placeholder={t('snippet.descriptionPlaceholder', { ns: 'workflow' }) || ''}
+ disabled={isSubmitting}
/>
-