mirror of
https://github.com/langgenius/dify.git
synced 2026-04-21 19:27:40 +08:00
feat(web): create snippet from workflow
This commit is contained in:
@ -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
|
||||
|
||||
@ -158,7 +158,6 @@ const SnippetInfoDropdown = ({ snippet }: SnippetInfoDropdownProps) => {
|
||||
{isEditDialogOpen && (
|
||||
<CreateSnippetDialog
|
||||
isOpen={isEditDialogOpen}
|
||||
selectedNodeIds={[]}
|
||||
initialValue={initialValue}
|
||||
title={t('editDialogTitle')}
|
||||
confirmText={t('operation.save', { ns: 'common' })}
|
||||
|
||||
@ -86,7 +86,6 @@ const SnippetCreateCard = () => {
|
||||
{isCreateDialogOpen && (
|
||||
<CreateSnippetDialog
|
||||
isOpen={isCreateDialogOpen}
|
||||
selectedNodeIds={[]}
|
||||
isSubmitting={createSnippetMutation.isPending}
|
||||
onClose={() => setIsCreateDialogOpen(false)}
|
||||
onConfirm={handleCreateSnippet}
|
||||
|
||||
@ -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 }),
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user