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} />
-