feat(web): create snippet from workflow

This commit is contained in:
JzoNg
2026-03-25 22:57:48 +08:00
parent 5e1f252046
commit 6318bf0a2a
6 changed files with 254 additions and 17 deletions

View File

@ -135,7 +135,11 @@ vi.mock('@/app/components/workflow/create-snippet-dialog', () => ({
icon: '✨',
background: '#FFFFFF',
},
selectedNodeIds: [],
graph: {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
})}
>
submit-edit

View File

@ -158,7 +158,6 @@ const SnippetInfoDropdown = ({ snippet }: SnippetInfoDropdownProps) => {
{isEditDialogOpen && (
<CreateSnippetDialog
isOpen={isEditDialogOpen}
selectedNodeIds={[]}
initialValue={initialValue}
title={t('editDialogTitle')}
confirmText={t('operation.save', { ns: 'common' })}

View File

@ -86,7 +86,6 @@ const SnippetCreateCard = () => {
{isCreateDialogOpen && (
<CreateSnippetDialog
isOpen={isCreateDialogOpen}
selectedNodeIds={[]}
isSubmitting={createSnippetMutation.isPending}
onClose={() => setIsCreateDialogOpen(false)}
onConfirm={handleCreateSnippet}

View File

@ -13,6 +13,39 @@ const mockGetNodesReadOnly = vi.fn()
const mockHandleNodesCopy = vi.fn()
const mockHandleNodesDelete = vi.fn()
const mockHandleNodesDuplicate = vi.fn()
const mockPush = vi.fn()
const mockToastSuccess = vi.fn()
const mockToastError = vi.fn()
const mockCreateSnippetMutateAsync = vi.fn()
const mockSyncDraftWorkflow = vi.fn()
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: mockPush,
}),
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
success: (...args: unknown[]) => mockToastSuccess(...args),
error: (...args: unknown[]) => mockToastError(...args),
},
}))
vi.mock('@/service/use-snippets', () => ({
useCreateSnippetMutation: () => ({
mutateAsync: mockCreateSnippetMutateAsync,
isPending: false,
}),
}))
vi.mock('@/service/client', () => ({
consoleClient: {
snippets: {
syncDraftWorkflow: (...args: unknown[]) => mockSyncDraftWorkflow(...args),
},
},
}))
vi.mock('../hooks', async () => {
const actual = await vi.importActual<typeof import('../hooks')>('../hooks')
@ -84,6 +117,11 @@ describe('SelectionContextmenu', () => {
mockHandleNodesCopy.mockReset()
mockHandleNodesDelete.mockReset()
mockHandleNodesDuplicate.mockReset()
mockPush.mockReset()
mockToastSuccess.mockReset()
mockToastError.mockReset()
mockCreateSnippetMutateAsync.mockReset()
mockSyncDraftWorkflow.mockReset()
})
it('should not render when selectionMenu is absent', () => {
@ -206,6 +244,74 @@ describe('SelectionContextmenu', () => {
expect(mockHandleNodesDelete).toHaveBeenCalledTimes(1)
})
it('should create a snippet with the selected graph and redirect to the snippet detail page', async () => {
mockCreateSnippetMutateAsync.mockResolvedValue({ id: 'snippet-123' })
mockSyncDraftWorkflow.mockResolvedValue({ result: 'success' })
const nodes = [
createNode({ id: 'n1', selected: true, position: { x: 120, y: 60 }, width: 40, height: 20 }),
createNode({ id: 'n2', selected: true, position: { x: 260, y: 120 }, width: 60, height: 30 }),
createNode({ id: 'n3', selected: false, position: { x: 500, y: 300 }, width: 40, height: 20 }),
]
const edges = [
createEdge({ id: 'e1', source: 'n1', target: 'n2' }),
createEdge({ id: 'e2', source: 'n2', target: 'n3' }),
]
const { store } = renderSelectionMenu({ nodes, edges })
act(() => {
store.setState({ selectionMenu: { left: 120, top: 120 } })
})
fireEvent.click(screen.getByTestId('selection-contextmenu-item-createSnippet'))
fireEvent.change(screen.getByPlaceholderText('workflow.snippet.namePlaceholder'), {
target: { value: 'My snippet' },
})
fireEvent.click(screen.getByRole('button', { name: /workflow\.snippet\.confirm/i }))
await waitFor(() => {
expect(mockCreateSnippetMutateAsync).toHaveBeenCalledWith({
body: expect.objectContaining({
name: 'My snippet',
}),
})
})
expect(mockSyncDraftWorkflow).toHaveBeenCalledWith({
params: { snippetId: 'snippet-123' },
body: {
graph: {
nodes: [
expect.objectContaining({
id: 'n1',
position: { x: 0, y: 0 },
selected: false,
data: expect.objectContaining({ selected: false }),
}),
expect.objectContaining({
id: 'n2',
position: { x: 140, y: 60 },
selected: false,
data: expect.objectContaining({ selected: false }),
}),
],
edges: [
expect.objectContaining({
id: 'e1',
source: 'n1',
target: 'n2',
selected: false,
}),
],
viewport: { x: 0, y: 0, zoom: 1 },
},
},
})
expect(mockToastSuccess).toHaveBeenCalledWith('workflow.snippet.createSuccess')
expect(mockPush).toHaveBeenCalledWith('/snippets/snippet-123/orchestrate')
})
it('should distribute selected nodes horizontally', async () => {
const nodes = [
createNode({ id: 'n1', selected: true, position: { x: 0, y: 10 }, width: 20, height: 20 }),

View File

@ -2,6 +2,7 @@
import type { FC } from 'react'
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
import type { SnippetCanvasData } from '@/models/snippet'
import { useKeyPress } from 'ahooks'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -17,7 +18,7 @@ export type CreateSnippetDialogPayload = {
name: string
description: string
icon: AppIconSelection
selectedNodeIds: string[]
graph: SnippetCanvasData
}
export type CreateSnippetDialogInitialValue = {
@ -28,7 +29,7 @@ export type CreateSnippetDialogInitialValue = {
type CreateSnippetDialogProps = {
isOpen: boolean
selectedNodeIds: string[]
selectedGraph?: SnippetCanvasData
onClose: () => void
onConfirm: (payload: CreateSnippetDialogPayload) => void
isSubmitting?: boolean
@ -43,9 +44,15 @@ const defaultIcon: AppIconSelection = {
background: '#FFEAD5',
}
const defaultGraph: SnippetCanvasData = {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
}
const CreateSnippetDialog: FC<CreateSnippetDialogProps> = ({
isOpen,
selectedNodeIds,
selectedGraph,
onClose,
onConfirm,
isSubmitting = false,
@ -82,11 +89,11 @@ const CreateSnippetDialog: FC<CreateSnippetDialogProps> = ({
name: trimmedName,
description: trimmedDescription,
icon,
selectedNodeIds,
graph: selectedGraph ?? defaultGraph,
}
onConfirm(payload)
}, [description, icon, name, onConfirm, selectedNodeIds])
}, [description, icon, name, onConfirm, selectedGraph])
useKeyPress(['meta.enter', 'ctrl.enter'], () => {
if (!isOpen)

View File

@ -1,5 +1,7 @@
import type { ComponentType } from 'react'
import type { Node } from './types'
import type { CreateSnippetDialogPayload } from './create-snippet-dialog'
import type { Edge, Node } from './types'
import type { SnippetCanvasData } from '@/models/snippet'
import {
RiAlignItemBottomLine,
RiAlignItemHorizontalCenterLine,
@ -25,6 +27,10 @@ import {
ContextMenuItem,
ContextMenuSeparator,
} from '@/app/components/base/ui/context-menu'
import { toast } from '@/app/components/base/ui/toast'
import { useRouter } from '@/next/navigation'
import { consoleClient } from '@/service/client'
import { useCreateSnippetMutation } from '@/service/use-snippets'
import { cn } from '@/utils/classnames'
import CreateSnippetDialog from './create-snippet-dialog'
import { useNodesInteractions, useNodesReadOnly, useNodesSyncDraft } from './hooks'
@ -77,6 +83,7 @@ type ActionMenuItem = {
const MENU_WIDTH = 240
const MENU_HEIGHT = 240
const DEFAULT_SNIPPET_VIEWPORT: SnippetCanvasData['viewport'] = { x: 0, y: 0, zoom: 1 }
const alignMenuItems: AlignMenuItem[] = [
{ alignType: AlignType.Left, icon: RiAlignItemLeftLine, translationKey: 'operator.alignLeft' },
@ -257,14 +264,88 @@ const distributeNodes = (
})
}
const getSelectedSnippetGraph = (
nodes: Node[],
edges: Edge[],
selectedNodes: Node[],
): SnippetCanvasData => {
const includedNodeIds = new Set(selectedNodes.map(node => node.id))
let shouldExpand = true
while (shouldExpand) {
shouldExpand = false
nodes.forEach((node) => {
if (!includedNodeIds.has(node.id))
return
if (node.parentId && !includedNodeIds.has(node.parentId)) {
includedNodeIds.add(node.parentId)
shouldExpand = true
}
node.data._children?.forEach((child) => {
if (!includedNodeIds.has(child.nodeId)) {
includedNodeIds.add(child.nodeId)
shouldExpand = true
}
})
})
}
const rootNodes = nodes.filter(node => includedNodeIds.has(node.id) && (!node.parentId || !includedNodeIds.has(node.parentId)))
const minRootX = rootNodes.length ? Math.min(...rootNodes.map(node => node.position.x)) : 0
const minRootY = rootNodes.length ? Math.min(...rootNodes.map(node => node.position.y)) : 0
return {
nodes: nodes
.filter(node => includedNodeIds.has(node.id))
.map((node) => {
const isRootNode = !node.parentId || !includedNodeIds.has(node.parentId)
const nextPosition = isRootNode
? { x: node.position.x - minRootX, y: node.position.y - minRootY }
: node.position
return {
...node,
position: nextPosition,
positionAbsolute: node.positionAbsolute
? (isRootNode
? {
x: node.positionAbsolute.x - minRootX,
y: node.positionAbsolute.y - minRootY,
}
: node.positionAbsolute)
: undefined,
selected: false,
data: {
...node.data,
selected: false,
_children: node.data._children?.filter(child => includedNodeIds.has(child.nodeId)),
},
}
}),
edges: edges
.filter(edge => includedNodeIds.has(edge.source) && includedNodeIds.has(edge.target))
.map(edge => ({
...edge,
selected: false,
})),
viewport: DEFAULT_SNIPPET_VIEWPORT,
}
}
const SelectionContextmenu = () => {
const { t } = useTranslation()
const { push } = useRouter()
const createSnippetMutation = useCreateSnippetMutation()
const { getNodesReadOnly } = useNodesReadOnly()
const { handleNodesCopy, handleNodesDelete, handleNodesDuplicate } = useNodesInteractions()
const { handleSelectionContextmenuCancel } = useSelectionInteractions()
const selectionMenu = useStore(s => s.selectionMenu)
const [isCreateSnippetDialogOpen, setIsCreateSnippetDialogOpen] = useState(false)
const [selectedNodeIdsSnapshot, setSelectedNodeIdsSnapshot] = useState<string[]>([])
const [isCreatingSnippet, setIsCreatingSnippet] = useState(false)
const [selectedGraphSnapshot, setSelectedGraphSnapshot] = useState<SnippetCanvasData | undefined>()
// Access React Flow methods
const store = useStoreApi()
@ -316,16 +397,58 @@ const SelectionContextmenu = () => {
if (isAddToSnippetDisabled)
return
setSelectedNodeIdsSnapshot(selectedNodes.map(node => node.id))
const nodes = store.getState().getNodes()
const { edges } = store.getState()
setSelectedGraphSnapshot(getSelectedSnippetGraph(nodes, edges, selectedNodes))
setIsCreateSnippetDialogOpen(true)
handleSelectionContextmenuCancel()
}, [handleSelectionContextmenuCancel, isAddToSnippetDisabled, selectedNodes])
}, [handleSelectionContextmenuCancel, isAddToSnippetDisabled, selectedNodes, store])
const handleCloseCreateSnippetDialog = useCallback(() => {
setIsCreateSnippetDialogOpen(false)
setSelectedNodeIdsSnapshot([])
setSelectedGraphSnapshot(undefined)
}, [])
const handleCreateSnippet = useCallback(async ({
name,
description,
icon,
graph,
}: CreateSnippetDialogPayload) => {
setIsCreatingSnippet(true)
try {
const snippet = await createSnippetMutation.mutateAsync({
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,
},
},
})
await consoleClient.snippets.syncDraftWorkflow({
params: { snippetId: snippet.id },
body: { graph },
})
toast.success(t('snippet.createSuccess', { ns: 'workflow' }))
handleCloseCreateSnippetDialog()
push(`/snippets/${snippet.id}/orchestrate`)
}
catch (error) {
toast.error(error instanceof Error ? error.message : t('createFailed', { ns: 'snippet' }))
}
finally {
setIsCreatingSnippet(false)
}
}, [createSnippetMutation, handleCloseCreateSnippetDialog, push, t])
const menuActions = useMemo<ActionMenuItem[]>(() => [
{
action: 'createSnippet',
@ -500,11 +623,10 @@ const SelectionContextmenu = () => {
{isCreateSnippetDialogOpen && (
<CreateSnippetDialog
isOpen={isCreateSnippetDialogOpen}
selectedNodeIds={selectedNodeIdsSnapshot}
selectedGraph={selectedGraphSnapshot}
isSubmitting={isCreatingSnippet || createSnippetMutation.isPending}
onClose={handleCloseCreateSnippetDialog}
onConfirm={(payload) => {
void payload
}}
onConfirm={handleCreateSnippet}
/>
)}
</div>