mirror of
https://github.com/langgenius/dify.git
synced 2026-05-05 18:00:59 -04:00
refactor(web): migrate workflow node actions menu (#35785)
This commit is contained in:
@@ -1,15 +1,24 @@
|
||||
import type { Node } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import NodeContextmenu from '../node-contextmenu'
|
||||
import { NodeContextmenu } from '../node-contextmenu'
|
||||
|
||||
const mockUseClickAway = vi.hoisted(() => vi.fn())
|
||||
const mockUseNodes = vi.hoisted(() => vi.fn())
|
||||
const mockUsePanelInteractions = vi.hoisted(() => vi.fn())
|
||||
const mockUseStore = vi.hoisted(() => vi.fn())
|
||||
const mockPanelOperatorPopup = vi.hoisted(() => vi.fn())
|
||||
const mockNodeActionsContextMenuContent = vi.hoisted(() => vi.fn())
|
||||
const mockContextMenuContent = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useClickAway: (...args: unknown[]) => mockUseClickAway(...args),
|
||||
vi.mock('@langgenius/dify-ui/context-menu', () => ({
|
||||
ContextMenu: ({ children, onOpenChange }: { children: React.ReactNode, onOpenChange: (open: boolean) => void }) => (
|
||||
<div>
|
||||
{children}
|
||||
<button type="button" onClick={() => onOpenChange(false)}>close-context-menu</button>
|
||||
</div>
|
||||
),
|
||||
ContextMenuContent: ({ children, positionerProps, popupClassName }: { children: React.ReactNode, positionerProps?: { anchor?: unknown }, popupClassName?: string }) => {
|
||||
mockContextMenuContent({ positionerProps, popupClassName })
|
||||
return <div>{children}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({
|
||||
@@ -22,20 +31,19 @@ vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useStore: (selector: (state: { nodeMenu?: { nodeId: string, left: number, top: number } }) => unknown) => mockUseStore(selector),
|
||||
useStore: (selector: (state: { nodeMenu?: { nodeId: string, clientX: number, clientY: number } }) => unknown) => mockUseStore(selector),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup', () => ({
|
||||
__esModule: true,
|
||||
default: (props: {
|
||||
vi.mock('@/app/components/workflow/node-actions-menu/context-menu-content', () => ({
|
||||
NodeActionsContextMenuContent: (props: {
|
||||
id: string
|
||||
data: Node['data']
|
||||
showHelpLink: boolean
|
||||
onClosePopup: () => void
|
||||
onClose: () => void
|
||||
}) => {
|
||||
mockPanelOperatorPopup(props)
|
||||
mockNodeActionsContextMenuContent(props)
|
||||
return (
|
||||
<button type="button" onClick={props.onClosePopup}>
|
||||
<button type="button" onClick={props.onClose}>
|
||||
{props.id}
|
||||
:
|
||||
{props.data.title}
|
||||
@@ -46,9 +54,8 @@ vi.mock('@/app/components/workflow/nodes/_base/components/panel-operator/panel-o
|
||||
|
||||
describe('NodeContextmenu', () => {
|
||||
const mockHandleNodeContextmenuCancel = vi.fn()
|
||||
let nodeMenu: { nodeId: string, left: number, top: number } | undefined
|
||||
let nodeMenu: { nodeId: string, clientX: number, clientY: number } | undefined
|
||||
let nodes: Node[]
|
||||
let clickAwayHandler: (() => void) | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -63,51 +70,50 @@ describe('NodeContextmenu', () => {
|
||||
type: 'code' as never,
|
||||
},
|
||||
} as Node]
|
||||
clickAwayHandler = undefined
|
||||
|
||||
mockUseClickAway.mockImplementation((handler: () => void) => {
|
||||
clickAwayHandler = handler
|
||||
})
|
||||
mockUseNodes.mockImplementation(() => nodes)
|
||||
mockUsePanelInteractions.mockReturnValue({
|
||||
handleNodeContextmenuCancel: mockHandleNodeContextmenuCancel,
|
||||
})
|
||||
mockUseStore.mockImplementation((selector: (state: { nodeMenu?: { nodeId: string, left: number, top: number } }) => unknown) => selector({ nodeMenu }))
|
||||
mockUseStore.mockImplementation((selector: (state: { nodeMenu?: { nodeId: string, clientX: number, clientY: number } }) => unknown) => selector({ nodeMenu }))
|
||||
})
|
||||
|
||||
it('should stay hidden when the node menu is absent', () => {
|
||||
render(<NodeContextmenu />)
|
||||
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
expect(mockPanelOperatorPopup).not.toHaveBeenCalled()
|
||||
expect(mockNodeActionsContextMenuContent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should stay hidden when the referenced node cannot be found', () => {
|
||||
nodeMenu = { nodeId: 'missing-node', left: 80, top: 120 }
|
||||
nodeMenu = { nodeId: 'missing-node', clientX: 80, clientY: 120 }
|
||||
|
||||
render(<NodeContextmenu />)
|
||||
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
expect(mockPanelOperatorPopup).not.toHaveBeenCalled()
|
||||
expect(mockNodeActionsContextMenuContent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render the popup at the stored position and close on popup/click-away actions', () => {
|
||||
nodeMenu = { nodeId: 'node-1', left: 80, top: 120 }
|
||||
const { container } = render(<NodeContextmenu />)
|
||||
it('should render the context menu at the stored pointer position and close on content/root actions', () => {
|
||||
nodeMenu = { nodeId: 'node-1', clientX: 80, clientY: 120 }
|
||||
render(<NodeContextmenu />)
|
||||
|
||||
expect(screen.getByRole('button')).toHaveTextContent('node-1:Node 1')
|
||||
expect(mockPanelOperatorPopup).toHaveBeenCalledWith(expect.objectContaining({
|
||||
expect(screen.getByText('node-1:Node 1')).toBeInTheDocument()
|
||||
expect(mockNodeActionsContextMenuContent).toHaveBeenCalledWith(expect.objectContaining({
|
||||
id: 'node-1',
|
||||
data: expect.objectContaining({ title: 'Node 1' }),
|
||||
showHelpLink: true,
|
||||
}))
|
||||
expect(container.firstChild).toHaveStyle({
|
||||
left: '80px',
|
||||
top: '120px',
|
||||
})
|
||||
expect(mockContextMenuContent).toHaveBeenCalledWith(expect.objectContaining({
|
||||
popupClassName: 'w-[240px] rounded-lg',
|
||||
}))
|
||||
const anchor = mockContextMenuContent.mock.calls[0]![0].positionerProps.anchor as { getBoundingClientRect: () => DOMRect }
|
||||
const rect = anchor.getBoundingClientRect()
|
||||
expect(rect.x).toBe(80)
|
||||
expect(rect.y).toBe(120)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
clickAwayHandler?.()
|
||||
fireEvent.click(screen.getByText('node-1:Node 1'))
|
||||
fireEvent.click(screen.getByText('close-context-menu'))
|
||||
|
||||
expect(mockHandleNodeContextmenuCancel).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
@@ -297,7 +297,7 @@ vi.mock('../edge-contextmenu', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../node-contextmenu', () => ({
|
||||
default: () => null,
|
||||
NodeContextmenu: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../nodes', () => ({
|
||||
|
||||
@@ -39,7 +39,7 @@ describe('usePanelInteractions', () => {
|
||||
it('handlePaneContextMenu should set panelMenu with computed coordinates when container exists', () => {
|
||||
const { result, store } = renderWorkflowHook(() => usePanelInteractions(), {
|
||||
initialStoreState: {
|
||||
nodeMenu: { top: 20, left: 40, nodeId: 'n1' },
|
||||
nodeMenu: { clientX: 40, clientY: 20, nodeId: 'n1' },
|
||||
selectionMenu: { clientX: 30, clientY: 50 },
|
||||
edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e1' },
|
||||
},
|
||||
@@ -116,7 +116,7 @@ describe('usePanelInteractions', () => {
|
||||
|
||||
it('handleNodeContextmenuCancel should clear nodeMenu', () => {
|
||||
const { result, store } = renderWorkflowHook(() => usePanelInteractions(), {
|
||||
initialStoreState: { nodeMenu: { top: 10, left: 20, nodeId: 'n1' } },
|
||||
initialStoreState: { nodeMenu: { clientX: 20, clientY: 10, nodeId: 'n1' } },
|
||||
})
|
||||
|
||||
result.current.handleNodeContextmenuCancel()
|
||||
|
||||
@@ -173,7 +173,7 @@ describe('useSelectionInteractions', () => {
|
||||
|
||||
it('handleSelectionContextMenu should set menu only when clicking on selection rect', () => {
|
||||
const { result, store } = renderSelectionInteractions({
|
||||
nodeMenu: { top: 10, left: 20, nodeId: 'n1' },
|
||||
nodeMenu: { clientX: 20, clientY: 10, nodeId: 'n1' },
|
||||
panelMenu: { top: 30, left: 40 },
|
||||
edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e1' },
|
||||
})
|
||||
|
||||
@@ -1700,15 +1700,13 @@ export const useNodesInteractions = () => {
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
const container = document.querySelector('#workflow-container')
|
||||
const { x, y } = container!.getBoundingClientRect()
|
||||
workflowStore.setState({
|
||||
panelMenu: undefined,
|
||||
selectionMenu: undefined,
|
||||
edgeMenu: undefined,
|
||||
nodeMenu: {
|
||||
top: e.clientY - y,
|
||||
left: e.clientX - x,
|
||||
clientX: e.clientX,
|
||||
clientY: e.clientY,
|
||||
nodeId: node.id,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -95,7 +95,7 @@ import {
|
||||
import { HooksStoreContextProvider, useHooksStore } from './hooks-store'
|
||||
import { useWorkflowComment } from './hooks/use-workflow-comment'
|
||||
import { useWorkflowSearch } from './hooks/use-workflow-search'
|
||||
import NodeContextmenu from './node-contextmenu'
|
||||
import { NodeContextmenu } from './node-contextmenu'
|
||||
import CustomNode from './nodes'
|
||||
import useMatchSchemaType from './nodes/_base/components/variable/use-match-schema-type'
|
||||
import CustomDataSourceEmptyNode from './nodes/data-source-empty'
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
/* eslint-disable ts/no-explicit-any */
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import {
|
||||
useAvailableBlocks,
|
||||
useIsChatMode,
|
||||
useNodeDataUpdate,
|
||||
useNodeMetaData,
|
||||
useNodesInteractions,
|
||||
useNodesReadOnly,
|
||||
useNodesSyncDraft,
|
||||
} from '@/app/components/workflow/hooks'
|
||||
import { useHooksStore } from '@/app/components/workflow/hooks-store'
|
||||
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { useAllWorkflowTools } from '@/service/use-tools'
|
||||
import { FlowType } from '@/types/common'
|
||||
import { ChangeBlockMenuTrigger } from '../change-block-menu-trigger'
|
||||
import { NodeActionsDropdownContent } from '../dropdown-content'
|
||||
|
||||
vi.mock('@/app/components/workflow/block-selector', () => ({
|
||||
default: ({ trigger, onSelect, availableBlocksTypes, showStartTab, ignoreNodeIds, forceEnableStartTab, allowUserInputSelection }: any) => (
|
||||
<div>
|
||||
<div>{trigger()}</div>
|
||||
<div>{`available:${(availableBlocksTypes || []).join(',')}`}</div>
|
||||
<div>{`show-start:${String(showStartTab)}`}</div>
|
||||
<div>{`ignore:${(ignoreNodeIds || []).join(',')}`}</div>
|
||||
<div>{`force-start:${String(forceEnableStartTab)}`}</div>
|
||||
<div>{`allow-start:${String(allowUserInputSelection)}`}</div>
|
||||
<button type="button" onClick={() => onSelect(BlockEnum.HttpRequest)}>select-http</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/app/components/workflow/hooks')>()
|
||||
return {
|
||||
...actual,
|
||||
useAvailableBlocks: vi.fn(),
|
||||
useIsChatMode: vi.fn(),
|
||||
useNodeDataUpdate: vi.fn(),
|
||||
useNodeMetaData: vi.fn(),
|
||||
useNodesInteractions: vi.fn(),
|
||||
useNodesReadOnly: vi.fn(),
|
||||
useNodesSyncDraft: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks-store', () => ({
|
||||
useHooksStore: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useAllWorkflowTools: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockUseAvailableBlocks = vi.mocked(useAvailableBlocks)
|
||||
const mockUseIsChatMode = vi.mocked(useIsChatMode)
|
||||
const mockUseNodeDataUpdate = vi.mocked(useNodeDataUpdate)
|
||||
const mockUseNodeMetaData = vi.mocked(useNodeMetaData)
|
||||
const mockUseNodesInteractions = vi.mocked(useNodesInteractions)
|
||||
const mockUseNodesReadOnly = vi.mocked(useNodesReadOnly)
|
||||
const mockUseNodesSyncDraft = vi.mocked(useNodesSyncDraft)
|
||||
const mockUseHooksStore = vi.mocked(useHooksStore)
|
||||
const mockUseNodes = vi.mocked(useNodes)
|
||||
const mockUseAllWorkflowTools = vi.mocked(useAllWorkflowTools)
|
||||
|
||||
function renderDropdownContent({
|
||||
showHelpLink = true,
|
||||
onClose = vi.fn(),
|
||||
}: {
|
||||
showHelpLink?: boolean
|
||||
onClose?: () => void
|
||||
} = {}) {
|
||||
return renderWorkflowFlowComponent(
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuTrigger render={<button type="button">open</button>} />
|
||||
<DropdownMenuContent>
|
||||
<NodeActionsDropdownContent
|
||||
id="node-1"
|
||||
data={{ type: BlockEnum.Code, title: 'Code Node', desc: '' } as any}
|
||||
onClose={onClose}
|
||||
showHelpLink={showHelpLink}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
{
|
||||
nodes: [],
|
||||
edges: [{ id: 'edge-1', source: 'node-0', target: 'node-1', sourceHandle: 'branch-a' }],
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
describe('node actions menu details', () => {
|
||||
const handleNodeChange = vi.fn()
|
||||
const handleNodeDelete = vi.fn()
|
||||
const handleNodesDuplicate = vi.fn()
|
||||
const handleNodeSelect = vi.fn()
|
||||
const handleNodesCopy = vi.fn()
|
||||
const handleNodeDataUpdate = vi.fn()
|
||||
const handleSyncWorkflowDraft = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseAvailableBlocks.mockReturnValue({
|
||||
getAvailableBlocks: vi.fn(() => ({
|
||||
availablePrevBlocks: [BlockEnum.HttpRequest],
|
||||
availableNextBlocks: [BlockEnum.HttpRequest],
|
||||
})),
|
||||
availablePrevBlocks: [BlockEnum.HttpRequest],
|
||||
availableNextBlocks: [BlockEnum.HttpRequest],
|
||||
} as ReturnType<typeof useAvailableBlocks>)
|
||||
mockUseIsChatMode.mockReturnValue(false)
|
||||
mockUseNodeDataUpdate.mockReturnValue({
|
||||
handleNodeDataUpdate,
|
||||
handleNodeDataUpdateWithSyncDraft: vi.fn(),
|
||||
})
|
||||
mockUseNodeMetaData.mockReturnValue({
|
||||
isTypeFixed: false,
|
||||
isSingleton: false,
|
||||
isUndeletable: false,
|
||||
description: 'Node description',
|
||||
author: 'Dify',
|
||||
helpLinkUri: 'https://docs.example.com/node',
|
||||
} as ReturnType<typeof useNodeMetaData>)
|
||||
mockUseNodesInteractions.mockReturnValue({
|
||||
handleNodeChange,
|
||||
handleNodeDelete,
|
||||
handleNodesDuplicate,
|
||||
handleNodeSelect,
|
||||
handleNodesCopy,
|
||||
} as unknown as ReturnType<typeof useNodesInteractions>)
|
||||
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false } as ReturnType<typeof useNodesReadOnly>)
|
||||
mockUseNodesSyncDraft.mockReturnValue({
|
||||
doSyncWorkflowDraft: vi.fn(),
|
||||
handleSyncWorkflowDraft,
|
||||
syncWorkflowDraftWhenPageClose: vi.fn(),
|
||||
} as ReturnType<typeof useNodesSyncDraft>)
|
||||
mockUseHooksStore.mockImplementation((selector: any) => selector({ configsMap: { flowType: FlowType.appFlow } }))
|
||||
mockUseNodes.mockReturnValue([{ id: 'start', position: { x: 0, y: 0 }, data: { type: BlockEnum.Start } as any }] as any)
|
||||
mockUseAllWorkflowTools.mockReturnValue({ data: [] } as any)
|
||||
})
|
||||
|
||||
it('should select a replacement block through ChangeBlockMenuTrigger', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<ChangeBlockMenuTrigger
|
||||
nodeId="node-1"
|
||||
nodeData={{ type: BlockEnum.Code } as any}
|
||||
sourceHandle="source"
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('select-http'))
|
||||
|
||||
expect(screen.getByText('available:http-request')).toBeInTheDocument()
|
||||
expect(screen.getByText('show-start:true')).toBeInTheDocument()
|
||||
expect(screen.getByText('ignore:')).toBeInTheDocument()
|
||||
expect(screen.getByText('force-start:false')).toBeInTheDocument()
|
||||
expect(screen.getByText('allow-start:false')).toBeInTheDocument()
|
||||
expect(handleNodeChange).toHaveBeenCalledWith('node-1', BlockEnum.HttpRequest, 'source', undefined)
|
||||
})
|
||||
|
||||
it('should expose trigger and start-node specific block selector options', () => {
|
||||
mockUseAvailableBlocks.mockReturnValueOnce({
|
||||
getAvailableBlocks: vi.fn(() => ({
|
||||
availablePrevBlocks: [],
|
||||
availableNextBlocks: [BlockEnum.HttpRequest],
|
||||
})),
|
||||
availablePrevBlocks: [],
|
||||
availableNextBlocks: [BlockEnum.HttpRequest],
|
||||
} as ReturnType<typeof useAvailableBlocks>)
|
||||
mockUseIsChatMode.mockReturnValueOnce(true)
|
||||
mockUseHooksStore.mockImplementationOnce((selector: any) => selector({ configsMap: { flowType: FlowType.appFlow } }))
|
||||
mockUseNodes.mockReturnValueOnce([] as any)
|
||||
|
||||
const { rerender } = render(
|
||||
<ChangeBlockMenuTrigger
|
||||
nodeId="trigger-node"
|
||||
nodeData={{ type: BlockEnum.TriggerWebhook } as any}
|
||||
sourceHandle="source"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('available:http-request')).toBeInTheDocument()
|
||||
expect(screen.getByText('show-start:true')).toBeInTheDocument()
|
||||
expect(screen.getByText('ignore:trigger-node')).toBeInTheDocument()
|
||||
expect(screen.getByText('allow-start:true')).toBeInTheDocument()
|
||||
|
||||
mockUseAvailableBlocks.mockReturnValueOnce({
|
||||
getAvailableBlocks: vi.fn(() => ({
|
||||
availablePrevBlocks: [BlockEnum.Code],
|
||||
availableNextBlocks: [],
|
||||
})),
|
||||
availablePrevBlocks: [BlockEnum.Code],
|
||||
availableNextBlocks: [],
|
||||
} as ReturnType<typeof useAvailableBlocks>)
|
||||
mockUseHooksStore.mockImplementationOnce((selector: any) => selector({ configsMap: { flowType: FlowType.ragPipeline } }))
|
||||
mockUseNodes.mockReturnValueOnce([{ id: 'start', position: { x: 0, y: 0 }, data: { type: BlockEnum.Start } as any }] as any)
|
||||
|
||||
rerender(
|
||||
<ChangeBlockMenuTrigger
|
||||
nodeId="start-node"
|
||||
nodeData={{ type: BlockEnum.Start } as any}
|
||||
sourceHandle="source"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('available:code')).toBeInTheDocument()
|
||||
expect(screen.getByText('show-start:false')).toBeInTheDocument()
|
||||
expect(screen.getByText('ignore:start-node')).toBeInTheDocument()
|
||||
expect(screen.getByText('force-start:true')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should run, copy, duplicate, delete, and expose the help link', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderDropdownContent()
|
||||
|
||||
await user.click(screen.getByText('workflow.panel.runThisStep'))
|
||||
await user.click(screen.getByText('workflow.common.copy'))
|
||||
await user.click(screen.getByText('workflow.common.duplicate'))
|
||||
await user.click(screen.getByText('common.operation.delete'))
|
||||
|
||||
expect(handleNodeSelect).toHaveBeenCalledWith('node-1')
|
||||
expect(handleNodeDataUpdate).toHaveBeenCalledWith({ id: 'node-1', data: { _isSingleRun: true } })
|
||||
expect(handleSyncWorkflowDraft).toHaveBeenCalledWith(true)
|
||||
expect(handleNodesCopy).toHaveBeenCalledWith('node-1')
|
||||
expect(handleNodesDuplicate).toHaveBeenCalledWith('node-1')
|
||||
expect(handleNodeDelete).toHaveBeenCalledWith('node-1')
|
||||
expect(screen.getByRole('menuitem', { name: 'workflow.panel.helpLink' })).toHaveAttribute('href', 'https://docs.example.com/node')
|
||||
})
|
||||
|
||||
it('should hide change action when node is undeletable', () => {
|
||||
mockUseNodeMetaData.mockReturnValueOnce({
|
||||
isTypeFixed: false,
|
||||
isSingleton: true,
|
||||
isUndeletable: true,
|
||||
description: 'Undeletable node',
|
||||
author: 'Dify',
|
||||
} as ReturnType<typeof useNodeMetaData>)
|
||||
|
||||
renderDropdownContent({ showHelpLink: false })
|
||||
|
||||
expect(screen.getByText('workflow.panel.runThisStep')).toBeInTheDocument()
|
||||
expect(screen.queryByText('workflow.panel.change')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render workflow-tool and readonly variants', () => {
|
||||
mockUseAllWorkflowTools.mockReturnValueOnce({
|
||||
data: [{ id: 'workflow-tool', workflow_app_id: 'app-123' }],
|
||||
} as any)
|
||||
|
||||
const { rerender } = renderWorkflowFlowComponent(
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuTrigger render={<button type="button">open</button>} />
|
||||
<DropdownMenuContent>
|
||||
<NodeActionsDropdownContent
|
||||
id="node-2"
|
||||
data={{ type: BlockEnum.Tool, title: 'Workflow Tool', desc: '', provider_type: 'workflow', provider_id: 'workflow-tool' } as any}
|
||||
onClose={vi.fn()}
|
||||
showHelpLink={false}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
{
|
||||
nodes: [],
|
||||
edges: [],
|
||||
},
|
||||
)
|
||||
|
||||
expect(screen.getByRole('menuitem', { name: 'workflow.panel.openWorkflow' })).toHaveAttribute('href', '/app/app-123/workflow')
|
||||
|
||||
mockUseNodesReadOnly.mockReturnValueOnce({ nodesReadOnly: true } as ReturnType<typeof useNodesReadOnly>)
|
||||
mockUseNodeMetaData.mockReturnValueOnce({
|
||||
isTypeFixed: true,
|
||||
isSingleton: true,
|
||||
isUndeletable: true,
|
||||
description: 'Read only node',
|
||||
author: 'Dify',
|
||||
} as ReturnType<typeof useNodeMetaData>)
|
||||
|
||||
rerender(
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuTrigger render={<button type="button">open</button>} />
|
||||
<DropdownMenuContent>
|
||||
<NodeActionsDropdownContent
|
||||
id="node-3"
|
||||
data={{ type: BlockEnum.End, title: 'Read only node', desc: '' } as any}
|
||||
onClose={vi.fn()}
|
||||
showHelpLink={false}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('workflow.panel.runThisStep')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('workflow.common.copy')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from '@/app/components/workflow/hooks'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { useAllWorkflowTools } from '@/service/use-tools'
|
||||
import PanelOperator from '../index'
|
||||
import { NodeActionsDropdown } from '../index'
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/app/components/workflow/hooks')>()
|
||||
@@ -30,8 +30,8 @@ vi.mock('@/service/use-tools', () => ({
|
||||
useAllWorkflowTools: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../change-block', () => ({
|
||||
default: () => <div data-testid="panel-operator-change-block" />,
|
||||
vi.mock('../change-block-menu-trigger', () => ({
|
||||
ChangeBlockMenuTrigger: () => <div data-testid="node-actions-change-block" />,
|
||||
}))
|
||||
|
||||
const mockUseNodeDataUpdate = vi.mocked(useNodeDataUpdate)
|
||||
@@ -73,18 +73,16 @@ const createQueryResult = <T,>(data: T): UseQueryResult<T, Error> => ({
|
||||
const renderComponent = (
|
||||
showHelpLink: boolean = true,
|
||||
onOpenChange?: (open: boolean) => void,
|
||||
offset?: { mainAxis: number, crossAxis: number } | number,
|
||||
) =>
|
||||
renderWorkflowFlowComponent(
|
||||
<PanelOperator
|
||||
<NodeActionsDropdown
|
||||
id="node-1"
|
||||
data={{
|
||||
title: 'Code Node',
|
||||
desc: '',
|
||||
type: BlockEnum.Code,
|
||||
}}
|
||||
triggerClassName="panel-operator-trigger"
|
||||
offset={offset}
|
||||
triggerClassName="node-actions-trigger"
|
||||
onOpenChange={onOpenChange}
|
||||
showHelpLink={showHelpLink}
|
||||
/>,
|
||||
@@ -94,7 +92,7 @@ const renderComponent = (
|
||||
},
|
||||
)
|
||||
|
||||
describe('PanelOperator', () => {
|
||||
describe('NodeActionsDropdown', () => {
|
||||
const handleNodeSelect = vi.fn()
|
||||
const handleNodeDataUpdate = vi.fn()
|
||||
const handleSyncWorkflowDraft = vi.fn()
|
||||
@@ -131,47 +129,34 @@ describe('PanelOperator', () => {
|
||||
mockUseAllWorkflowTools.mockReturnValue(createQueryResult<ToolWithProvider[]>([]))
|
||||
})
|
||||
|
||||
// The operator should open the real popup, expose actionable items, and respect help-link visibility.
|
||||
describe('Popup Interaction', () => {
|
||||
it('should open the popup and trigger single-run actions', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onOpenChange = vi.fn()
|
||||
const { container } = renderComponent(true, onOpenChange)
|
||||
it('should open the dropdown and trigger single-run actions', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onOpenChange = vi.fn()
|
||||
renderComponent(true, onOpenChange)
|
||||
|
||||
await user.click(container.querySelector('.panel-operator-trigger') as HTMLElement)
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.more' }))
|
||||
|
||||
expect(onOpenChange).toHaveBeenCalledWith(true)
|
||||
expect(screen.getByText('workflow.panel.runThisStep')).toBeInTheDocument()
|
||||
expect(screen.getByText('Node description')).toBeInTheDocument()
|
||||
expect(onOpenChange).toHaveBeenCalledWith(true)
|
||||
expect(screen.getByText('workflow.panel.runThisStep')).toBeInTheDocument()
|
||||
expect(screen.getByText('Node description')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('workflow.panel.runThisStep'))
|
||||
await user.click(screen.getByText('workflow.panel.runThisStep'))
|
||||
|
||||
expect(handleNodeSelect).toHaveBeenCalledWith('node-1')
|
||||
expect(handleNodeDataUpdate).toHaveBeenCalledWith({
|
||||
id: 'node-1',
|
||||
data: { _isSingleRun: true },
|
||||
})
|
||||
expect(handleSyncWorkflowDraft).toHaveBeenCalledWith(true)
|
||||
expect(handleNodeSelect).toHaveBeenCalledWith('node-1')
|
||||
expect(handleNodeDataUpdate).toHaveBeenCalledWith({
|
||||
id: 'node-1',
|
||||
data: { _isSingleRun: true },
|
||||
})
|
||||
expect(handleSyncWorkflowDraft).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should hide the help link when showHelpLink is false', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { container } = renderComponent(false)
|
||||
it('should hide the help link when showHelpLink is false', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderComponent(false)
|
||||
|
||||
await user.click(container.querySelector('.panel-operator-trigger') as HTMLElement)
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.more' }))
|
||||
|
||||
expect(screen.queryByText('workflow.panel.helpLink')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Node description')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should still open the popup when using a numeric offset and no open-change callback', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { container } = renderComponent(true, undefined, 0)
|
||||
|
||||
await user.click(container.querySelector('.panel-operator-trigger') as HTMLElement)
|
||||
|
||||
expect(screen.getByText('workflow.panel.runThisStep')).toBeInTheDocument()
|
||||
expect(screen.getByText('Node description')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.queryByText('workflow.panel.helpLink')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Node description')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -4,11 +4,7 @@ import type {
|
||||
OnSelectBlock,
|
||||
} from '@/app/components/workflow/types'
|
||||
import { intersection } from 'es-toolkit/array'
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from 'react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import BlockSelector from '@/app/components/workflow/block-selector'
|
||||
import {
|
||||
@@ -19,19 +15,19 @@ import {
|
||||
import { useHooksStore } from '@/app/components/workflow/hooks-store'
|
||||
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
|
||||
import { BlockEnum, isTriggerNode } from '@/app/components/workflow/types'
|
||||
|
||||
import { FlowType } from '@/types/common'
|
||||
|
||||
type ChangeBlockProps = {
|
||||
type ChangeBlockMenuTriggerProps = {
|
||||
nodeId: string
|
||||
nodeData: Node['data']
|
||||
sourceHandle: string
|
||||
}
|
||||
const ChangeBlock = ({
|
||||
|
||||
export function ChangeBlockMenuTrigger({
|
||||
nodeId,
|
||||
nodeData,
|
||||
sourceHandle,
|
||||
}: ChangeBlockProps) => {
|
||||
}: ChangeBlockMenuTriggerProps) {
|
||||
const { t } = useTranslation()
|
||||
const { handleNodeChange } = useNodesInteractions()
|
||||
const {
|
||||
@@ -55,10 +51,9 @@ const ChangeBlock = ({
|
||||
const availableNodes = useMemo(() => {
|
||||
if (availablePrevBlocks.length && availableNextBlocks.length)
|
||||
return intersection(availablePrevBlocks, availableNextBlocks)
|
||||
else if (availablePrevBlocks.length)
|
||||
if (availablePrevBlocks.length)
|
||||
return availablePrevBlocks
|
||||
else
|
||||
return availableNextBlocks
|
||||
return availableNextBlocks
|
||||
}, [availablePrevBlocks, availableNextBlocks])
|
||||
|
||||
const handleSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => {
|
||||
@@ -67,19 +62,17 @@ const ChangeBlock = ({
|
||||
|
||||
const renderTrigger = useCallback(() => {
|
||||
return (
|
||||
<div className="flex h-8 w-[232px] cursor-pointer items-center rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover">
|
||||
<button
|
||||
type="button"
|
||||
className="mx-1 flex h-8 w-[calc(100%-8px)] cursor-pointer items-center rounded-lg border-0 bg-transparent px-2 text-left text-sm text-text-secondary outline-hidden select-none hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:outline-hidden"
|
||||
>
|
||||
{t('panel.changeBlock', { ns: 'workflow' })}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}, [t])
|
||||
|
||||
return (
|
||||
<BlockSelector
|
||||
placement="bottom-end"
|
||||
offset={{
|
||||
mainAxis: -36,
|
||||
crossAxis: 4,
|
||||
}}
|
||||
onSelect={handleSelect}
|
||||
trigger={renderTrigger}
|
||||
popupClassName="min-w-[240px]"
|
||||
@@ -91,5 +84,3 @@ const ChangeBlock = ({
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ChangeBlock)
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { NodeActionsMenuProps } from './types'
|
||||
import {
|
||||
ContextMenuGroup,
|
||||
ContextMenuItem,
|
||||
ContextMenuLinkItem,
|
||||
ContextMenuSeparator,
|
||||
} from '@langgenius/dify-ui/context-menu'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ChangeBlockMenuTrigger } from './change-block-menu-trigger'
|
||||
import {
|
||||
NODE_ACTIONS_MENU_ITEM_WITH_SHORTCUT_CLASS_NAME,
|
||||
NodeActionsMenuAbout,
|
||||
NodeActionsMenuItemContent,
|
||||
} from './shared'
|
||||
import { useNodeActionsMenuModel } from './use-node-actions-menu-model'
|
||||
|
||||
export function NodeActionsContextMenuContent(props: NodeActionsMenuProps) {
|
||||
const { t } = useTranslation()
|
||||
const model = useNodeActionsMenuModel(props)
|
||||
const hasRunGroup = model.canRun || model.canChangeBlock
|
||||
const hasEditGroup = !model.nodesReadOnly && !model.isSingleton
|
||||
const hasDeleteGroup = !model.nodesReadOnly && !model.isUndeletable
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasRunGroup && (
|
||||
<ContextMenuGroup>
|
||||
{model.canRun && (
|
||||
<ContextMenuItem onClick={model.handleRun}>
|
||||
{t('panel.runThisStep', { ns: 'workflow' })}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{model.canChangeBlock && (
|
||||
<ChangeBlockMenuTrigger
|
||||
nodeId={model.id}
|
||||
nodeData={model.data}
|
||||
sourceHandle={model.sourceHandle}
|
||||
/>
|
||||
)}
|
||||
</ContextMenuGroup>
|
||||
)}
|
||||
{hasRunGroup && (hasEditGroup || hasDeleteGroup || model.workflowAppHref || model.helpLinkUri) && <ContextMenuSeparator />}
|
||||
{hasEditGroup && (
|
||||
<ContextMenuGroup>
|
||||
<ContextMenuItem
|
||||
className={NODE_ACTIONS_MENU_ITEM_WITH_SHORTCUT_CLASS_NAME}
|
||||
onClick={model.handleCopy}
|
||||
>
|
||||
<NodeActionsMenuItemContent shortcut="workflow.copy">
|
||||
{t('common.copy', { ns: 'workflow' })}
|
||||
</NodeActionsMenuItemContent>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className={NODE_ACTIONS_MENU_ITEM_WITH_SHORTCUT_CLASS_NAME}
|
||||
onClick={model.handleDuplicate}
|
||||
>
|
||||
<NodeActionsMenuItemContent shortcut="workflow.duplicate">
|
||||
{t('common.duplicate', { ns: 'workflow' })}
|
||||
</NodeActionsMenuItemContent>
|
||||
</ContextMenuItem>
|
||||
</ContextMenuGroup>
|
||||
)}
|
||||
{hasEditGroup && (hasDeleteGroup || model.workflowAppHref || model.helpLinkUri) && <ContextMenuSeparator />}
|
||||
{hasDeleteGroup && (
|
||||
<ContextMenuGroup>
|
||||
<ContextMenuItem
|
||||
variant="destructive"
|
||||
className={NODE_ACTIONS_MENU_ITEM_WITH_SHORTCUT_CLASS_NAME}
|
||||
onClick={model.handleDelete}
|
||||
>
|
||||
<NodeActionsMenuItemContent shortcut="workflow.delete">
|
||||
{t('operation.delete', { ns: 'common' })}
|
||||
</NodeActionsMenuItemContent>
|
||||
</ContextMenuItem>
|
||||
</ContextMenuGroup>
|
||||
)}
|
||||
{hasDeleteGroup && (model.workflowAppHref || model.helpLinkUri) && <ContextMenuSeparator />}
|
||||
{model.workflowAppHref && (
|
||||
<ContextMenuGroup>
|
||||
<ContextMenuLinkItem href={model.workflowAppHref} target="_blank" rel="noopener noreferrer">
|
||||
{t('panel.openWorkflow', { ns: 'workflow' })}
|
||||
</ContextMenuLinkItem>
|
||||
</ContextMenuGroup>
|
||||
)}
|
||||
{model.workflowAppHref && model.helpLinkUri && <ContextMenuSeparator />}
|
||||
{model.helpLinkUri && (
|
||||
<ContextMenuGroup>
|
||||
<ContextMenuLinkItem href={model.helpLinkUri} target="_blank" rel="noopener noreferrer">
|
||||
{t('panel.helpLink', { ns: 'workflow' })}
|
||||
</ContextMenuLinkItem>
|
||||
</ContextMenuGroup>
|
||||
)}
|
||||
<ContextMenuSeparator />
|
||||
<NodeActionsMenuAbout
|
||||
title={t('panel.about', { ns: 'workflow' })}
|
||||
description={model.about.description}
|
||||
author={`${t('panel.createdBy', { ns: 'workflow' })} ${model.about.author}`}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { NodeActionsMenuProps } from './types'
|
||||
import {
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLinkItem,
|
||||
DropdownMenuSeparator,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ChangeBlockMenuTrigger } from './change-block-menu-trigger'
|
||||
import {
|
||||
NODE_ACTIONS_MENU_ITEM_WITH_SHORTCUT_CLASS_NAME,
|
||||
NodeActionsMenuAbout,
|
||||
NodeActionsMenuItemContent,
|
||||
} from './shared'
|
||||
import { useNodeActionsMenuModel } from './use-node-actions-menu-model'
|
||||
|
||||
export function NodeActionsDropdownContent(props: NodeActionsMenuProps) {
|
||||
const { t } = useTranslation()
|
||||
const model = useNodeActionsMenuModel(props)
|
||||
const hasRunGroup = model.canRun || model.canChangeBlock
|
||||
const hasEditGroup = !model.nodesReadOnly && !model.isSingleton
|
||||
const hasDeleteGroup = !model.nodesReadOnly && !model.isUndeletable
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasRunGroup && (
|
||||
<DropdownMenuGroup>
|
||||
{model.canRun && (
|
||||
<DropdownMenuItem onClick={model.handleRun}>
|
||||
{t('panel.runThisStep', { ns: 'workflow' })}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{model.canChangeBlock && (
|
||||
<ChangeBlockMenuTrigger
|
||||
nodeId={model.id}
|
||||
nodeData={model.data}
|
||||
sourceHandle={model.sourceHandle}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
)}
|
||||
{hasRunGroup && (hasEditGroup || hasDeleteGroup || model.workflowAppHref || model.helpLinkUri) && <DropdownMenuSeparator />}
|
||||
{hasEditGroup && (
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
className={NODE_ACTIONS_MENU_ITEM_WITH_SHORTCUT_CLASS_NAME}
|
||||
onClick={model.handleCopy}
|
||||
>
|
||||
<NodeActionsMenuItemContent shortcut="workflow.copy">
|
||||
{t('common.copy', { ns: 'workflow' })}
|
||||
</NodeActionsMenuItemContent>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className={NODE_ACTIONS_MENU_ITEM_WITH_SHORTCUT_CLASS_NAME}
|
||||
onClick={model.handleDuplicate}
|
||||
>
|
||||
<NodeActionsMenuItemContent shortcut="workflow.duplicate">
|
||||
{t('common.duplicate', { ns: 'workflow' })}
|
||||
</NodeActionsMenuItemContent>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
)}
|
||||
{hasEditGroup && (hasDeleteGroup || model.workflowAppHref || model.helpLinkUri) && <DropdownMenuSeparator />}
|
||||
{hasDeleteGroup && (
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
className={NODE_ACTIONS_MENU_ITEM_WITH_SHORTCUT_CLASS_NAME}
|
||||
onClick={model.handleDelete}
|
||||
>
|
||||
<NodeActionsMenuItemContent shortcut="workflow.delete">
|
||||
{t('operation.delete', { ns: 'common' })}
|
||||
</NodeActionsMenuItemContent>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
)}
|
||||
{hasDeleteGroup && (model.workflowAppHref || model.helpLinkUri) && <DropdownMenuSeparator />}
|
||||
{model.workflowAppHref && (
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLinkItem href={model.workflowAppHref} target="_blank" rel="noopener noreferrer">
|
||||
{t('panel.openWorkflow', { ns: 'workflow' })}
|
||||
</DropdownMenuLinkItem>
|
||||
</DropdownMenuGroup>
|
||||
)}
|
||||
{model.workflowAppHref && model.helpLinkUri && <DropdownMenuSeparator />}
|
||||
{model.helpLinkUri && (
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLinkItem href={model.helpLinkUri} target="_blank" rel="noopener noreferrer">
|
||||
{t('panel.helpLink', { ns: 'workflow' })}
|
||||
</DropdownMenuLinkItem>
|
||||
</DropdownMenuGroup>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<NodeActionsMenuAbout
|
||||
title={t('panel.about', { ns: 'workflow' })}
|
||||
description={model.about.description}
|
||||
author={`${t('panel.createdBy', { ns: 'workflow' })} ${model.about.author}`}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
75
web/app/components/workflow/node-actions-menu/index.tsx
Normal file
75
web/app/components/workflow/node-actions-menu/index.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { Node } from '@/app/components/workflow/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { NodeActionsDropdownContent } from './dropdown-content'
|
||||
import { NODE_ACTIONS_MENU_WIDTH_CLASS_NAME } from './shared'
|
||||
|
||||
type NodeActionsDropdownProps = {
|
||||
id: string
|
||||
data: Node['data']
|
||||
triggerClassName?: string
|
||||
onOpenChange?: (open: boolean) => void
|
||||
showHelpLink?: boolean
|
||||
}
|
||||
|
||||
export function NodeActionsDropdown({
|
||||
id,
|
||||
data,
|
||||
triggerClassName,
|
||||
onOpenChange,
|
||||
showHelpLink = true,
|
||||
}: NodeActionsDropdownProps) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const handleOpenChange = useCallback((nextOpen: boolean) => {
|
||||
setOpen(nextOpen)
|
||||
onOpenChange?.(nextOpen)
|
||||
}, [onOpenChange])
|
||||
|
||||
const closeMenu = useCallback(() => {
|
||||
setOpen(false)
|
||||
onOpenChange?.(false)
|
||||
}, [onOpenChange])
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
modal={false}
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
<DropdownMenuTrigger
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('operation.more', { ns: 'common' })}
|
||||
className={cn(
|
||||
'flex h-6 w-6 cursor-pointer items-center justify-center rounded-md border-0 bg-transparent p-0 text-text-tertiary hover:bg-state-base-hover',
|
||||
'focus-visible:ring-1 focus-visible:ring-components-input-border-hover focus-visible:outline-hidden data-popup-open:bg-state-base-hover',
|
||||
triggerClassName,
|
||||
)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-more-fill h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
popupClassName={NODE_ACTIONS_MENU_WIDTH_CLASS_NAME}
|
||||
>
|
||||
<NodeActionsDropdownContent
|
||||
id={id}
|
||||
data={data}
|
||||
onClose={closeMenu}
|
||||
showHelpLink={showHelpLink}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
44
web/app/components/workflow/node-actions-menu/shared.tsx
Normal file
44
web/app/components/workflow/node-actions-menu/shared.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { RegisterableHotkey } from '@tanstack/react-hotkeys'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { WorkflowShortcutId } from '@/app/components/workflow/shortcuts/definitions'
|
||||
import { ShortcutKbd } from '@/app/components/workflow/shortcuts/shortcut-kbd'
|
||||
|
||||
export const NODE_ACTIONS_MENU_WIDTH_CLASS_NAME = 'w-[240px] rounded-lg'
|
||||
export const NODE_ACTIONS_MENU_ITEM_WITH_SHORTCUT_CLASS_NAME = 'w-auto justify-between gap-4'
|
||||
|
||||
export function NodeActionsMenuItemContent({
|
||||
children,
|
||||
hotkey,
|
||||
shortcut,
|
||||
}: {
|
||||
children: ReactNode
|
||||
hotkey?: RegisterableHotkey | (string & {})
|
||||
shortcut?: WorkflowShortcutId
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<span className="min-w-0 truncate">{children}</span>
|
||||
{(shortcut || hotkey) && <ShortcutKbd shortcut={shortcut} hotkey={hotkey} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function NodeActionsMenuAbout({
|
||||
author,
|
||||
description,
|
||||
title,
|
||||
}: {
|
||||
author?: string
|
||||
description?: string
|
||||
title: string
|
||||
}) {
|
||||
return (
|
||||
<div className="px-3 py-2 text-xs text-text-tertiary">
|
||||
<div className="mb-1 flex h-[22px] items-center font-medium">
|
||||
{title.toLocaleUpperCase()}
|
||||
</div>
|
||||
<div className="mb-1 leading-[18px] text-text-secondary">{description}</div>
|
||||
<div className="leading-[18px]">{author}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
8
web/app/components/workflow/node-actions-menu/types.ts
Normal file
8
web/app/components/workflow/node-actions-menu/types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { Node } from '@/app/components/workflow/types'
|
||||
|
||||
export type NodeActionsMenuProps = {
|
||||
id: string
|
||||
data: Node['data']
|
||||
onClose: () => void
|
||||
showHelpLink?: boolean
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { Node } from '@/app/components/workflow/types'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useEdges } from 'reactflow'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import {
|
||||
useNodeDataUpdate,
|
||||
useNodeMetaData,
|
||||
useNodesInteractions,
|
||||
useNodesReadOnly,
|
||||
useNodesSyncDraft,
|
||||
} from '@/app/components/workflow/hooks'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { canRunBySingle } from '@/app/components/workflow/utils'
|
||||
import { useAllWorkflowTools } from '@/service/use-tools'
|
||||
import { canFindTool } from '@/utils'
|
||||
|
||||
type UseNodeActionsMenuModelParams = {
|
||||
id: string
|
||||
data: Node['data']
|
||||
onClose: () => void
|
||||
showHelpLink?: boolean
|
||||
}
|
||||
|
||||
export function useNodeActionsMenuModel({
|
||||
id,
|
||||
data,
|
||||
onClose,
|
||||
showHelpLink = true,
|
||||
}: UseNodeActionsMenuModelParams) {
|
||||
const edges = useEdges()
|
||||
const {
|
||||
handleNodeDelete,
|
||||
handleNodesDuplicate,
|
||||
handleNodeSelect,
|
||||
handleNodesCopy,
|
||||
} = useNodesInteractions()
|
||||
const { handleNodeDataUpdate } = useNodeDataUpdate()
|
||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
const { nodesReadOnly } = useNodesReadOnly()
|
||||
const nodeMetaData = useNodeMetaData({ id, data } as Node)
|
||||
const { data: workflowTools } = useAllWorkflowTools()
|
||||
|
||||
const isChildNode = !!(data.isInIteration || data.isInLoop)
|
||||
const canRun = canRunBySingle(data.type, isChildNode)
|
||||
const canChangeBlock = !nodeMetaData.isTypeFixed && !nodeMetaData.isUndeletable && !nodesReadOnly
|
||||
const sourceHandle = useMemo(() => {
|
||||
return edges.find(edge => edge.target === id)?.sourceHandle || 'source'
|
||||
}, [edges, id])
|
||||
|
||||
const workflowAppHref = useMemo(() => {
|
||||
const isWorkflowTool = data.type === BlockEnum.Tool && data.provider_type === CollectionType.workflow
|
||||
if (!isWorkflowTool || !workflowTools || !data.provider_id)
|
||||
return undefined
|
||||
|
||||
const workflowTool = workflowTools.find(item => canFindTool(item.id, data.provider_id))
|
||||
if (!workflowTool?.workflow_app_id)
|
||||
return undefined
|
||||
|
||||
return `/app/${workflowTool.workflow_app_id}/workflow`
|
||||
}, [data.provider_id, data.provider_type, data.type, workflowTools])
|
||||
|
||||
const handleRun = useCallback(() => {
|
||||
handleNodeSelect(id)
|
||||
handleNodeDataUpdate({ id, data: { _isSingleRun: true } })
|
||||
handleSyncWorkflowDraft(true)
|
||||
onClose()
|
||||
}, [handleNodeDataUpdate, handleNodeSelect, handleSyncWorkflowDraft, id, onClose])
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
onClose()
|
||||
handleNodesCopy(id)
|
||||
}, [handleNodesCopy, id, onClose])
|
||||
|
||||
const handleDuplicate = useCallback(() => {
|
||||
onClose()
|
||||
handleNodesDuplicate(id)
|
||||
}, [handleNodesDuplicate, id, onClose])
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
onClose()
|
||||
handleNodeDelete(id)
|
||||
}, [handleNodeDelete, id, onClose])
|
||||
|
||||
return {
|
||||
about: {
|
||||
author: nodeMetaData.author,
|
||||
description: nodeMetaData.description,
|
||||
},
|
||||
canChangeBlock,
|
||||
canRun,
|
||||
data,
|
||||
handleCopy,
|
||||
handleDelete,
|
||||
handleDuplicate,
|
||||
handleRun,
|
||||
helpLinkUri: showHelpLink ? nodeMetaData.helpLinkUri : undefined,
|
||||
id,
|
||||
isSingleton: nodeMetaData.isSingleton,
|
||||
isUndeletable: nodeMetaData.isUndeletable,
|
||||
nodesReadOnly,
|
||||
sourceHandle,
|
||||
workflowAppHref,
|
||||
}
|
||||
}
|
||||
@@ -1,45 +1,54 @@
|
||||
import type { Node } from './types'
|
||||
import { useClickAway } from 'ahooks'
|
||||
import {
|
||||
memo,
|
||||
useRef,
|
||||
} from 'react'
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
} from '@langgenius/dify-ui/context-menu'
|
||||
import { useMemo } from 'react'
|
||||
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
|
||||
import { usePanelInteractions } from './hooks'
|
||||
import PanelOperatorPopup from './nodes/_base/components/panel-operator/panel-operator-popup'
|
||||
import { NodeActionsContextMenuContent } from './node-actions-menu/context-menu-content'
|
||||
import { NODE_ACTIONS_MENU_WIDTH_CLASS_NAME } from './node-actions-menu/shared'
|
||||
import { useStore } from './store'
|
||||
|
||||
const NodeContextmenu = () => {
|
||||
const ref = useRef(null)
|
||||
export function NodeContextmenu() {
|
||||
const nodes = useNodes()
|
||||
const { handleNodeContextmenuCancel } = usePanelInteractions()
|
||||
const nodeMenu = useStore(s => s.nodeMenu)
|
||||
const currentNode = nodes.find(node => node.id === nodeMenu?.nodeId) as Node
|
||||
|
||||
useClickAway(() => {
|
||||
handleNodeContextmenuCancel()
|
||||
}, ref)
|
||||
const anchor = useMemo(() => {
|
||||
if (!nodeMenu || !currentNode)
|
||||
return undefined
|
||||
|
||||
if (!nodeMenu || !currentNode)
|
||||
return {
|
||||
getBoundingClientRect: () => DOMRect.fromRect({
|
||||
width: 0,
|
||||
height: 0,
|
||||
x: nodeMenu.clientX,
|
||||
y: nodeMenu.clientY,
|
||||
}),
|
||||
}
|
||||
}, [currentNode, nodeMenu])
|
||||
|
||||
if (!nodeMenu || !currentNode || !anchor)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute z-9"
|
||||
style={{
|
||||
left: nodeMenu.left,
|
||||
top: nodeMenu.top,
|
||||
}}
|
||||
ref={ref}
|
||||
<ContextMenu
|
||||
open
|
||||
onOpenChange={open => !open && handleNodeContextmenuCancel()}
|
||||
>
|
||||
<PanelOperatorPopup
|
||||
id={currentNode.id}
|
||||
data={currentNode.data}
|
||||
onClosePopup={() => handleNodeContextmenuCancel()}
|
||||
showHelpLink
|
||||
/>
|
||||
</div>
|
||||
<ContextMenuContent
|
||||
positionerProps={{ anchor }}
|
||||
popupClassName={NODE_ACTIONS_MENU_WIDTH_CLASS_NAME}
|
||||
>
|
||||
<NodeActionsContextMenuContent
|
||||
id={currentNode.id}
|
||||
data={currentNode.data}
|
||||
onClose={handleNodeContextmenuCancel}
|
||||
showHelpLink
|
||||
/>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(NodeContextmenu)
|
||||
|
||||
@@ -32,8 +32,8 @@ vi.mock('../../../../utils', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../panel-operator', () => ({
|
||||
default: ({ onOpenChange }: { onOpenChange: (open: boolean) => void }) => (
|
||||
vi.mock('@/app/components/workflow/node-actions-menu', () => ({
|
||||
NodeActionsDropdown: ({ onOpenChange }: { onOpenChange: (open: boolean) => void }) => (
|
||||
<>
|
||||
<button type="button" onClick={() => onOpenChange(true)}>open panel</button>
|
||||
<button type="button" onClick={() => onOpenChange(false)}>close panel</button>
|
||||
|
||||
@@ -11,13 +11,13 @@ import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Stop,
|
||||
} from '@/app/components/base/icons/src/vender/line/mediaAndDevices'
|
||||
import { NodeActionsDropdown } from '@/app/components/workflow/node-actions-menu'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import {
|
||||
useNodesInteractions,
|
||||
} from '../../../hooks'
|
||||
import { NodeRunningStatus } from '../../../types'
|
||||
import { canRunBySingle } from '../../../utils'
|
||||
import PanelOperator from './panel-operator'
|
||||
|
||||
type NodeControlProps = Pick<Node, 'id' | 'data'> & {
|
||||
pluginInstallLocked?: boolean
|
||||
@@ -82,10 +82,9 @@ const NodeControl: FC<NodeControlProps> = ({
|
||||
</button>
|
||||
)
|
||||
}
|
||||
<PanelOperator
|
||||
<NodeActionsDropdown
|
||||
id={id}
|
||||
data={data}
|
||||
offset={0}
|
||||
triggerClassName="w-5! h-5!"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,295 +0,0 @@
|
||||
/* eslint-disable ts/no-explicit-any */
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import {
|
||||
useAvailableBlocks,
|
||||
useIsChatMode,
|
||||
useNodeDataUpdate,
|
||||
useNodeMetaData,
|
||||
useNodesInteractions,
|
||||
useNodesReadOnly,
|
||||
useNodesSyncDraft,
|
||||
} from '@/app/components/workflow/hooks'
|
||||
import { useHooksStore } from '@/app/components/workflow/hooks-store'
|
||||
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { useAllWorkflowTools } from '@/service/use-tools'
|
||||
import { FlowType } from '@/types/common'
|
||||
import ChangeBlock from '../change-block'
|
||||
import PanelOperatorPopup from '../panel-operator-popup'
|
||||
|
||||
vi.mock('@/app/components/workflow/block-selector', () => ({
|
||||
default: ({ trigger, onSelect, availableBlocksTypes, showStartTab, ignoreNodeIds, forceEnableStartTab, allowUserInputSelection }: any) => (
|
||||
<div>
|
||||
<div>{trigger()}</div>
|
||||
<div>{`available:${(availableBlocksTypes || []).join(',')}`}</div>
|
||||
<div>{`show-start:${String(showStartTab)}`}</div>
|
||||
<div>{`ignore:${(ignoreNodeIds || []).join(',')}`}</div>
|
||||
<div>{`force-start:${String(forceEnableStartTab)}`}</div>
|
||||
<div>{`allow-start:${String(allowUserInputSelection)}`}</div>
|
||||
<button type="button" onClick={() => onSelect(BlockEnum.HttpRequest)}>select-http</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/app/components/workflow/hooks')>()
|
||||
return {
|
||||
...actual,
|
||||
useAvailableBlocks: vi.fn(),
|
||||
useIsChatMode: vi.fn(),
|
||||
useNodeDataUpdate: vi.fn(),
|
||||
useNodeMetaData: vi.fn(),
|
||||
useNodesInteractions: vi.fn(),
|
||||
useNodesReadOnly: vi.fn(),
|
||||
useNodesSyncDraft: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks-store', () => ({
|
||||
useHooksStore: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useAllWorkflowTools: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockUseAvailableBlocks = vi.mocked(useAvailableBlocks)
|
||||
const mockUseIsChatMode = vi.mocked(useIsChatMode)
|
||||
const mockUseNodeDataUpdate = vi.mocked(useNodeDataUpdate)
|
||||
const mockUseNodeMetaData = vi.mocked(useNodeMetaData)
|
||||
const mockUseNodesInteractions = vi.mocked(useNodesInteractions)
|
||||
const mockUseNodesReadOnly = vi.mocked(useNodesReadOnly)
|
||||
const mockUseNodesSyncDraft = vi.mocked(useNodesSyncDraft)
|
||||
const mockUseHooksStore = vi.mocked(useHooksStore)
|
||||
const mockUseNodes = vi.mocked(useNodes)
|
||||
const mockUseAllWorkflowTools = vi.mocked(useAllWorkflowTools)
|
||||
|
||||
describe('panel-operator details', () => {
|
||||
const handleNodeChange = vi.fn()
|
||||
const handleNodeDelete = vi.fn()
|
||||
const handleNodesDuplicate = vi.fn()
|
||||
const handleNodeSelect = vi.fn()
|
||||
const handleNodesCopy = vi.fn()
|
||||
const handleNodeDataUpdate = vi.fn()
|
||||
const handleSyncWorkflowDraft = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseAvailableBlocks.mockReturnValue({
|
||||
getAvailableBlocks: vi.fn(() => ({
|
||||
availablePrevBlocks: [BlockEnum.HttpRequest],
|
||||
availableNextBlocks: [BlockEnum.HttpRequest],
|
||||
})),
|
||||
availablePrevBlocks: [BlockEnum.HttpRequest],
|
||||
availableNextBlocks: [BlockEnum.HttpRequest],
|
||||
} as ReturnType<typeof useAvailableBlocks>)
|
||||
mockUseIsChatMode.mockReturnValue(false)
|
||||
mockUseNodeDataUpdate.mockReturnValue({
|
||||
handleNodeDataUpdate,
|
||||
handleNodeDataUpdateWithSyncDraft: vi.fn(),
|
||||
})
|
||||
mockUseNodeMetaData.mockReturnValue({
|
||||
isTypeFixed: false,
|
||||
isSingleton: false,
|
||||
isUndeletable: false,
|
||||
description: 'Node description',
|
||||
author: 'Dify',
|
||||
helpLinkUri: 'https://docs.example.com/node',
|
||||
} as ReturnType<typeof useNodeMetaData>)
|
||||
mockUseNodesInteractions.mockReturnValue({
|
||||
handleNodeChange,
|
||||
handleNodeDelete,
|
||||
handleNodesDuplicate,
|
||||
handleNodeSelect,
|
||||
handleNodesCopy,
|
||||
} as unknown as ReturnType<typeof useNodesInteractions>)
|
||||
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false } as ReturnType<typeof useNodesReadOnly>)
|
||||
mockUseNodesSyncDraft.mockReturnValue({
|
||||
doSyncWorkflowDraft: vi.fn(),
|
||||
handleSyncWorkflowDraft,
|
||||
syncWorkflowDraftWhenPageClose: vi.fn(),
|
||||
} as ReturnType<typeof useNodesSyncDraft>)
|
||||
mockUseHooksStore.mockImplementation((selector: any) => selector({ configsMap: { flowType: FlowType.appFlow } }))
|
||||
mockUseNodes.mockReturnValue([{ id: 'start', position: { x: 0, y: 0 }, data: { type: BlockEnum.Start } as any }] as any)
|
||||
mockUseAllWorkflowTools.mockReturnValue({ data: [] } as any)
|
||||
})
|
||||
|
||||
// The panel operator internals should expose block-change and popup actions using the real workflow popup composition.
|
||||
describe('Internal Actions', () => {
|
||||
it('should select a replacement block through ChangeBlock', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<ChangeBlock
|
||||
nodeId="node-1"
|
||||
nodeData={{ type: BlockEnum.Code } as any}
|
||||
sourceHandle="source"
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('select-http'))
|
||||
|
||||
expect(screen.getByText('available:http-request')).toBeInTheDocument()
|
||||
expect(screen.getByText('show-start:true')).toBeInTheDocument()
|
||||
expect(screen.getByText('ignore:')).toBeInTheDocument()
|
||||
expect(screen.getByText('force-start:false')).toBeInTheDocument()
|
||||
expect(screen.getByText('allow-start:false')).toBeInTheDocument()
|
||||
expect(handleNodeChange).toHaveBeenCalledWith('node-1', BlockEnum.HttpRequest, 'source', undefined)
|
||||
})
|
||||
|
||||
it('should expose trigger and start-node specific block selector options', () => {
|
||||
mockUseAvailableBlocks.mockReturnValueOnce({
|
||||
getAvailableBlocks: vi.fn(() => ({
|
||||
availablePrevBlocks: [],
|
||||
availableNextBlocks: [BlockEnum.HttpRequest],
|
||||
})),
|
||||
availablePrevBlocks: [],
|
||||
availableNextBlocks: [BlockEnum.HttpRequest],
|
||||
} as ReturnType<typeof useAvailableBlocks>)
|
||||
mockUseIsChatMode.mockReturnValueOnce(true)
|
||||
mockUseHooksStore.mockImplementationOnce((selector: any) => selector({ configsMap: { flowType: FlowType.appFlow } }))
|
||||
mockUseNodes.mockReturnValueOnce([] as any)
|
||||
|
||||
const { rerender } = render(
|
||||
<ChangeBlock
|
||||
nodeId="trigger-node"
|
||||
nodeData={{ type: BlockEnum.TriggerWebhook } as any}
|
||||
sourceHandle="source"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('available:http-request')).toBeInTheDocument()
|
||||
expect(screen.getByText('show-start:true')).toBeInTheDocument()
|
||||
expect(screen.getByText('ignore:trigger-node')).toBeInTheDocument()
|
||||
expect(screen.getByText('allow-start:true')).toBeInTheDocument()
|
||||
|
||||
mockUseAvailableBlocks.mockReturnValueOnce({
|
||||
getAvailableBlocks: vi.fn(() => ({
|
||||
availablePrevBlocks: [BlockEnum.Code],
|
||||
availableNextBlocks: [],
|
||||
})),
|
||||
availablePrevBlocks: [BlockEnum.Code],
|
||||
availableNextBlocks: [],
|
||||
} as ReturnType<typeof useAvailableBlocks>)
|
||||
mockUseHooksStore.mockImplementationOnce((selector: any) => selector({ configsMap: { flowType: FlowType.ragPipeline } }))
|
||||
mockUseNodes.mockReturnValueOnce([{ id: 'start', position: { x: 0, y: 0 }, data: { type: BlockEnum.Start } as any }] as any)
|
||||
|
||||
rerender(
|
||||
<ChangeBlock
|
||||
nodeId="start-node"
|
||||
nodeData={{ type: BlockEnum.Start } as any}
|
||||
sourceHandle="source"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('available:code')).toBeInTheDocument()
|
||||
expect(screen.getByText('show-start:false')).toBeInTheDocument()
|
||||
expect(screen.getByText('ignore:start-node')).toBeInTheDocument()
|
||||
expect(screen.getByText('force-start:true')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should run, copy, duplicate, delete, and expose the help link in the popup', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWorkflowFlowComponent(
|
||||
<PanelOperatorPopup
|
||||
id="node-1"
|
||||
data={{ type: BlockEnum.Code, title: 'Code Node', desc: '' } as any}
|
||||
onClosePopup={vi.fn()}
|
||||
showHelpLink
|
||||
/>,
|
||||
{
|
||||
nodes: [],
|
||||
edges: [{ id: 'edge-1', source: 'node-0', target: 'node-1', sourceHandle: 'branch-a' }],
|
||||
},
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('workflow.panel.runThisStep'))
|
||||
await user.click(screen.getByText('workflow.common.copy'))
|
||||
await user.click(screen.getByText('workflow.common.duplicate'))
|
||||
await user.click(screen.getByText('common.operation.delete'))
|
||||
|
||||
expect(handleNodeSelect).toHaveBeenCalledWith('node-1')
|
||||
expect(handleNodeDataUpdate).toHaveBeenCalledWith({ id: 'node-1', data: { _isSingleRun: true } })
|
||||
expect(handleSyncWorkflowDraft).toHaveBeenCalledWith(true)
|
||||
expect(handleNodesCopy).toHaveBeenCalledWith('node-1')
|
||||
expect(handleNodesDuplicate).toHaveBeenCalledWith('node-1')
|
||||
expect(handleNodeDelete).toHaveBeenCalledWith('node-1')
|
||||
expect(screen.getByRole('link', { name: 'workflow.panel.helpLink' })).toHaveAttribute('href', 'https://docs.example.com/node')
|
||||
})
|
||||
|
||||
it('should hide change action when node is undeletable', () => {
|
||||
mockUseNodeMetaData.mockReturnValueOnce({
|
||||
isTypeFixed: false,
|
||||
isSingleton: true,
|
||||
isUndeletable: true,
|
||||
description: 'Undeletable node',
|
||||
author: 'Dify',
|
||||
} as ReturnType<typeof useNodeMetaData>)
|
||||
|
||||
renderWorkflowFlowComponent(
|
||||
<PanelOperatorPopup
|
||||
id="node-4"
|
||||
data={{ type: BlockEnum.Code, title: 'Undeletable node', desc: '' } as any}
|
||||
onClosePopup={vi.fn()}
|
||||
showHelpLink={false}
|
||||
/>,
|
||||
{
|
||||
nodes: [],
|
||||
edges: [],
|
||||
},
|
||||
)
|
||||
|
||||
expect(screen.getByText('workflow.panel.runThisStep')).toBeInTheDocument()
|
||||
expect(screen.queryByText('workflow.panel.change')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render workflow-tool and readonly popup variants', () => {
|
||||
mockUseAllWorkflowTools.mockReturnValueOnce({
|
||||
data: [{ id: 'workflow-tool', workflow_app_id: 'app-123' }],
|
||||
} as any)
|
||||
|
||||
const { rerender } = renderWorkflowFlowComponent(
|
||||
<PanelOperatorPopup
|
||||
id="node-2"
|
||||
data={{ type: BlockEnum.Tool, title: 'Workflow Tool', desc: '', provider_type: 'workflow', provider_id: 'workflow-tool' } as any}
|
||||
onClosePopup={vi.fn()}
|
||||
showHelpLink={false}
|
||||
/>,
|
||||
{
|
||||
nodes: [],
|
||||
edges: [],
|
||||
},
|
||||
)
|
||||
|
||||
expect(screen.getByRole('link', { name: 'workflow.panel.openWorkflow' })).toHaveAttribute('href', '/app/app-123/workflow')
|
||||
|
||||
mockUseNodesReadOnly.mockReturnValueOnce({ nodesReadOnly: true } as ReturnType<typeof useNodesReadOnly>)
|
||||
mockUseNodeMetaData.mockReturnValueOnce({
|
||||
isTypeFixed: true,
|
||||
isSingleton: true,
|
||||
isUndeletable: true,
|
||||
description: 'Read only node',
|
||||
author: 'Dify',
|
||||
} as ReturnType<typeof useNodeMetaData>)
|
||||
|
||||
rerender(
|
||||
<PanelOperatorPopup
|
||||
id="node-3"
|
||||
data={{ type: BlockEnum.End, title: 'Read only node', desc: '' } as any}
|
||||
onClosePopup={vi.fn()}
|
||||
showHelpLink={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('workflow.panel.runThisStep')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('workflow.common.copy')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,86 +0,0 @@
|
||||
import type { OffsetOptions } from '@floating-ui/react'
|
||||
import type { Node } from '@/app/components/workflow/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import PanelOperatorPopup from './panel-operator-popup'
|
||||
|
||||
type PanelOperatorProps = {
|
||||
id: string
|
||||
data: Node['data']
|
||||
triggerClassName?: string
|
||||
offset?: OffsetOptions | number
|
||||
onOpenChange?: (open: boolean) => void
|
||||
showHelpLink?: boolean
|
||||
}
|
||||
const PanelOperator = ({
|
||||
id,
|
||||
data,
|
||||
triggerClassName,
|
||||
offset = {
|
||||
mainAxis: 4,
|
||||
crossAxis: 53,
|
||||
},
|
||||
onOpenChange,
|
||||
showHelpLink = true,
|
||||
}: PanelOperatorProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const sideOffset = typeof offset === 'number'
|
||||
? offset
|
||||
: typeof offset === 'object' && offset && 'mainAxis' in offset && typeof offset.mainAxis === 'number'
|
||||
? offset.mainAxis
|
||||
: 4
|
||||
const alignOffset = typeof offset === 'object' && offset && 'crossAxis' in offset && typeof offset.crossAxis === 'number'
|
||||
? offset.crossAxis
|
||||
: 0
|
||||
|
||||
const handleOpenChange = useCallback((nextOpen: boolean) => {
|
||||
setOpen(nextOpen)
|
||||
onOpenChange?.(nextOpen)
|
||||
}, [onOpenChange])
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
modal={false}
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
<DropdownMenuTrigger
|
||||
render={<button type="button" />}
|
||||
aria-label={t('operation.more', { ns: 'common' })}
|
||||
className={cn(
|
||||
'nodrag nopan nowheel flex h-6 w-6 cursor-pointer items-center justify-center rounded-md hover:bg-state-base-hover',
|
||||
'data-[popup-open]:bg-state-base-hover',
|
||||
triggerClassName,
|
||||
)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-text-tertiary" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
sideOffset={sideOffset}
|
||||
alignOffset={alignOffset}
|
||||
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||
>
|
||||
<PanelOperatorPopup
|
||||
id={id}
|
||||
data={data}
|
||||
onClosePopup={() => setOpen(false)}
|
||||
showHelpLink={showHelpLink}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(PanelOperator)
|
||||
@@ -1,209 +0,0 @@
|
||||
import type { Node } from '@/app/components/workflow/types'
|
||||
import {
|
||||
memo,
|
||||
useMemo,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useEdges } from 'reactflow'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import {
|
||||
useNodeDataUpdate,
|
||||
useNodeMetaData,
|
||||
useNodesInteractions,
|
||||
useNodesReadOnly,
|
||||
useNodesSyncDraft,
|
||||
} from '@/app/components/workflow/hooks'
|
||||
import { ShortcutKbd } from '@/app/components/workflow/shortcuts/shortcut-kbd'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import {
|
||||
canRunBySingle,
|
||||
} from '@/app/components/workflow/utils'
|
||||
import { useAllWorkflowTools } from '@/service/use-tools'
|
||||
import { canFindTool } from '@/utils'
|
||||
import ChangeBlock from './change-block'
|
||||
|
||||
type PanelOperatorPopupProps = {
|
||||
id: string
|
||||
data: Node['data']
|
||||
onClosePopup: () => void
|
||||
showHelpLink?: boolean
|
||||
}
|
||||
const PanelOperatorPopup = ({
|
||||
id,
|
||||
data,
|
||||
onClosePopup,
|
||||
showHelpLink,
|
||||
}: PanelOperatorPopupProps) => {
|
||||
const { t } = useTranslation()
|
||||
const edges = useEdges()
|
||||
const {
|
||||
handleNodeDelete,
|
||||
handleNodesDuplicate,
|
||||
handleNodeSelect,
|
||||
handleNodesCopy,
|
||||
} = useNodesInteractions()
|
||||
const { handleNodeDataUpdate } = useNodeDataUpdate()
|
||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
const { nodesReadOnly } = useNodesReadOnly()
|
||||
const edge = edges.find(edge => edge.target === id)
|
||||
const nodeMetaData = useNodeMetaData({ id, data } as Node)
|
||||
const showChangeBlock = !nodeMetaData.isTypeFixed && !nodeMetaData.isUndeletable && !nodesReadOnly
|
||||
const isChildNode = !!(data.isInIteration || data.isInLoop)
|
||||
|
||||
const { data: workflowTools } = useAllWorkflowTools()
|
||||
const isWorkflowTool = data.type === BlockEnum.Tool && data.provider_type === CollectionType.workflow
|
||||
const workflowAppId = useMemo(() => {
|
||||
if (!isWorkflowTool || !workflowTools || !data.provider_id)
|
||||
return undefined
|
||||
const workflowTool = workflowTools.find(item => canFindTool(item.id, data.provider_id))
|
||||
return workflowTool?.workflow_app_id
|
||||
}, [isWorkflowTool, workflowTools, data.provider_id])
|
||||
|
||||
return (
|
||||
<div className="w-[240px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl">
|
||||
{
|
||||
(showChangeBlock || canRunBySingle(data.type, isChildNode)) && (
|
||||
<>
|
||||
<div className="p-1">
|
||||
{
|
||||
canRunBySingle(data.type, isChildNode) && (
|
||||
<button
|
||||
type="button"
|
||||
className={`
|
||||
flex h-8 w-full cursor-pointer items-center rounded-lg px-3 text-sm text-text-secondary
|
||||
hover:bg-state-base-hover
|
||||
`}
|
||||
onClick={() => {
|
||||
handleNodeSelect(id)
|
||||
handleNodeDataUpdate({ id, data: { _isSingleRun: true } })
|
||||
handleSyncWorkflowDraft(true)
|
||||
onClosePopup()
|
||||
}}
|
||||
>
|
||||
{t('panel.runThisStep', { ns: 'workflow' })}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
{
|
||||
showChangeBlock && (
|
||||
<ChangeBlock
|
||||
nodeId={id}
|
||||
nodeData={data}
|
||||
sourceHandle={edge?.sourceHandle || 'source'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div className="h-px bg-divider-regular"></div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
!nodesReadOnly && (
|
||||
<>
|
||||
{
|
||||
!nodeMetaData.isSingleton && (
|
||||
<>
|
||||
<div className="p-1">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-full cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
onClosePopup()
|
||||
handleNodesCopy(id)
|
||||
}}
|
||||
>
|
||||
{t('common.copy', { ns: 'workflow' })}
|
||||
<ShortcutKbd shortcut="workflow.copy" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-full cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
onClosePopup()
|
||||
handleNodesDuplicate(id)
|
||||
}}
|
||||
>
|
||||
{t('common.duplicate', { ns: 'workflow' })}
|
||||
<ShortcutKbd shortcut="workflow.duplicate" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="h-px bg-divider-regular"></div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
!nodeMetaData.isUndeletable && (
|
||||
<>
|
||||
<div className="p-1">
|
||||
<button
|
||||
type="button"
|
||||
className={`
|
||||
flex h-8 w-full cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary
|
||||
hover:bg-state-destructive-hover hover:text-text-destructive
|
||||
`}
|
||||
onClick={() => handleNodeDelete(id)}
|
||||
>
|
||||
{t('operation.delete', { ns: 'common' })}
|
||||
<ShortcutKbd shortcut="workflow.delete" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="h-px bg-divider-regular"></div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
isWorkflowTool && workflowAppId && (
|
||||
<>
|
||||
<div className="p-1">
|
||||
<a
|
||||
href={`/app/${workflowAppId}/workflow`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex h-8 cursor-pointer items-center rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
>
|
||||
{t('panel.openWorkflow', { ns: 'workflow' })}
|
||||
</a>
|
||||
</div>
|
||||
<div className="h-px bg-divider-regular"></div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
showHelpLink && nodeMetaData.helpLinkUri && (
|
||||
<>
|
||||
<div className="p-1">
|
||||
<a
|
||||
href={nodeMetaData.helpLinkUri}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex h-8 cursor-pointer items-center rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
>
|
||||
{t('panel.helpLink', { ns: 'workflow' })}
|
||||
</a>
|
||||
</div>
|
||||
<div className="h-px bg-divider-regular"></div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
<div className="p-1">
|
||||
<div className="px-3 py-2 text-xs text-text-tertiary">
|
||||
<div className="mb-1 flex h-[22px] items-center font-medium">
|
||||
{t('panel.about', { ns: 'workflow' }).toLocaleUpperCase()}
|
||||
</div>
|
||||
<div className="mb-1 leading-[18px] text-text-secondary">{nodeMetaData.description}</div>
|
||||
<div className="leading-[18px]">
|
||||
{t('panel.createdBy', { ns: 'workflow' })}
|
||||
{' '}
|
||||
{nodeMetaData.author}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(PanelOperatorPopup)
|
||||
@@ -241,8 +241,8 @@ vi.mock('../next-step', () => ({
|
||||
default: () => <div>next-step</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../panel-operator', () => ({
|
||||
default: () => <div>panel-operator</div>,
|
||||
vi.mock('@/app/components/workflow/node-actions-menu', () => ({
|
||||
NodeActionsDropdown: () => <div>node-actions-menu</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../retry/retry-on-panel', () => ({
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
} from '@/app/components/workflow/hooks'
|
||||
import { useHooksStore } from '@/app/components/workflow/hooks-store'
|
||||
import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud'
|
||||
import { NodeActionsDropdown } from '@/app/components/workflow/node-actions-menu'
|
||||
import Split from '@/app/components/workflow/nodes/_base/components/split'
|
||||
import { useLogs } from '@/app/components/workflow/run/hooks'
|
||||
import SpecialResultPanel from '@/app/components/workflow/run/special-result-panel'
|
||||
@@ -75,7 +76,6 @@ import PanelWrap from '../before-run-form/panel-wrap'
|
||||
import ErrorHandleOnPanel from '../error-handle/error-handle-on-panel'
|
||||
import HelpLink from '../help-link'
|
||||
import NextStep from '../next-step'
|
||||
import PanelOperator from '../panel-operator'
|
||||
import RetryOnPanel from '../retry/retry-on-panel'
|
||||
import { DescriptionInput, TitleInput } from '../title-description-input'
|
||||
import {
|
||||
@@ -554,7 +554,7 @@ const BasePanel: FC<BasePanelProps> = ({
|
||||
)
|
||||
}
|
||||
<HelpLink nodeType={data.type} />
|
||||
<PanelOperator id={id} data={data} showHelpLink={false} />
|
||||
<NodeActionsDropdown id={id} data={data} showHelpLink={false} />
|
||||
<div className="mx-3 h-3.5 w-px bg-divider-regular" />
|
||||
<div
|
||||
className="flex h-6 w-6 cursor-pointer items-center justify-center"
|
||||
|
||||
@@ -88,7 +88,7 @@ describe('createWorkflowStore', () => {
|
||||
['showSingleRunPanel', 'setShowSingleRunPanel', true],
|
||||
['nodeAnimation', 'setNodeAnimation', true],
|
||||
['candidateNode', 'setCandidateNode', undefined],
|
||||
['nodeMenu', 'setNodeMenu', { top: 100, left: 200, nodeId: 'n1' }],
|
||||
['nodeMenu', 'setNodeMenu', { clientX: 200, clientY: 100, nodeId: 'n1' }],
|
||||
['showAssignVariablePopup', 'setShowAssignVariablePopup', undefined],
|
||||
['hoveringAssignVariableGroupId', 'setHoveringAssignVariableGroupId', 'group-1'],
|
||||
['connectingNodePayload', 'setConnectingNodePayload', { nodeId: 'n1', nodeType: 'llm', handleType: 'source', handleId: 'h1' }],
|
||||
|
||||
@@ -19,8 +19,8 @@ export type NodeSliceShape = {
|
||||
candidateNode?: Node
|
||||
setCandidateNode: (candidateNode?: Node) => void
|
||||
nodeMenu?: {
|
||||
top: number
|
||||
left: number
|
||||
clientX: number
|
||||
clientY: number
|
||||
nodeId: string
|
||||
}
|
||||
setNodeMenu: (nodeMenu: NodeSliceShape['nodeMenu']) => void
|
||||
|
||||
Reference in New Issue
Block a user