feat(web): add snippet creation dialog flow

This commit is contained in:
JzoNg 2026-03-23 11:28:28 +08:00
parent a716d8789d
commit feef2dd1fa
5 changed files with 167 additions and 20 deletions

View File

@ -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')
})
})
})

View File

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

View File

@ -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' })}

View File

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

View File

@ -1,5 +1,8 @@
{
"create": "创建 Snippet",
"createFailed": "创建 Snippet 失败",
"createFromBlank": "创建空白 Snippet",
"defaultName": "未命名 Snippet",
"inputFieldButton": "输入字段",
"notFoundDescription": "未找到对应的 snippet 静态数据。",
"notFoundTitle": "未找到 Snippet",