refactor(web): migrate workflow node actions menu (#35785)

This commit is contained in:
yyh
2026-05-04 21:24:29 +08:00
committed by GitHub
parent 1359c03216
commit 8f3e42e9c2
25 changed files with 876 additions and 734 deletions

View File

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

View File

@@ -0,0 +1,162 @@
import type { UseQueryResult } from '@tanstack/react-query'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import {
useNodeDataUpdate,
useNodeMetaData,
useNodesInteractions,
useNodesReadOnly,
useNodesSyncDraft,
} from '@/app/components/workflow/hooks'
import { BlockEnum } from '@/app/components/workflow/types'
import { useAllWorkflowTools } from '@/service/use-tools'
import { NodeActionsDropdown } from '../index'
vi.mock('@/app/components/workflow/hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/workflow/hooks')>()
return {
...actual,
useNodeDataUpdate: vi.fn(),
useNodeMetaData: vi.fn(),
useNodesInteractions: vi.fn(),
useNodesReadOnly: vi.fn(),
useNodesSyncDraft: vi.fn(),
}
})
vi.mock('@/service/use-tools', () => ({
useAllWorkflowTools: vi.fn(),
}))
vi.mock('../change-block-menu-trigger', () => ({
ChangeBlockMenuTrigger: () => <div data-testid="node-actions-change-block" />,
}))
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 mockUseAllWorkflowTools = vi.mocked(useAllWorkflowTools)
const createQueryResult = <T,>(data: T): UseQueryResult<T, Error> => ({
data,
error: null,
refetch: vi.fn(),
isError: false,
isPending: false,
isLoading: false,
isSuccess: true,
isFetching: false,
isRefetching: false,
isLoadingError: false,
isRefetchError: false,
isInitialLoading: false,
isPaused: false,
isEnabled: true,
status: 'success',
fetchStatus: 'idle',
dataUpdatedAt: Date.now(),
errorUpdatedAt: 0,
failureCount: 0,
failureReason: null,
errorUpdateCount: 0,
isFetched: true,
isFetchedAfterMount: true,
isPlaceholderData: false,
isStale: false,
promise: Promise.resolve(data),
} as UseQueryResult<T, Error>)
const renderComponent = (
showHelpLink: boolean = true,
onOpenChange?: (open: boolean) => void,
) =>
renderWorkflowFlowComponent(
<NodeActionsDropdown
id="node-1"
data={{
title: 'Code Node',
desc: '',
type: BlockEnum.Code,
}}
triggerClassName="node-actions-trigger"
onOpenChange={onOpenChange}
showHelpLink={showHelpLink}
/>,
{
nodes: [],
edges: [],
},
)
describe('NodeActionsDropdown', () => {
const handleNodeSelect = vi.fn()
const handleNodeDataUpdate = vi.fn()
const handleSyncWorkflowDraft = vi.fn()
const handleNodeDelete = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
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({
handleNodeDelete,
handleNodesDuplicate: vi.fn(),
handleNodeSelect,
handleNodesCopy: vi.fn(),
} as unknown as ReturnType<typeof useNodesInteractions>)
mockUseNodesReadOnly.mockReturnValue({
nodesReadOnly: false,
} as ReturnType<typeof useNodesReadOnly>)
mockUseNodesSyncDraft.mockReturnValue({
doSyncWorkflowDraft: vi.fn().mockResolvedValue(undefined),
handleSyncWorkflowDraft,
syncWorkflowDraftWhenPageClose: vi.fn(),
})
mockUseAllWorkflowTools.mockReturnValue(createQueryResult<ToolWithProvider[]>([]))
})
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(screen.getByRole('button', { name: 'common.operation.more' }))
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'))
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()
renderComponent(false)
await user.click(screen.getByRole('button', { name: 'common.operation.more' }))
expect(screen.queryByText('workflow.panel.helpLink')).not.toBeInTheDocument()
expect(screen.getByText('Node description')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,86 @@
import type {
CommonNodeType,
Node,
OnSelectBlock,
} from '@/app/components/workflow/types'
import { intersection } from 'es-toolkit/array'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import BlockSelector from '@/app/components/workflow/block-selector'
import {
useAvailableBlocks,
useIsChatMode,
useNodesInteractions,
} 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, isTriggerNode } from '@/app/components/workflow/types'
import { FlowType } from '@/types/common'
type ChangeBlockMenuTriggerProps = {
nodeId: string
nodeData: Node['data']
sourceHandle: string
}
export function ChangeBlockMenuTrigger({
nodeId,
nodeData,
sourceHandle,
}: ChangeBlockMenuTriggerProps) {
const { t } = useTranslation()
const { handleNodeChange } = useNodesInteractions()
const {
availablePrevBlocks,
availableNextBlocks,
} = useAvailableBlocks(nodeData.type, nodeData.isInIteration || nodeData.isInLoop)
const isChatMode = useIsChatMode()
const flowType = useHooksStore(s => s.configsMap?.flowType)
const nodes = useNodes()
const hasStartNode = useMemo(() => {
return nodes.some(n => (n.data as CommonNodeType | undefined)?.type === BlockEnum.Start)
}, [nodes])
const showStartTab = flowType !== FlowType.ragPipeline && (!isChatMode || nodeData.type === BlockEnum.Start || !hasStartNode)
const ignoreNodeIds = useMemo(() => {
if (isTriggerNode(nodeData.type as BlockEnum) || nodeData.type === BlockEnum.Start)
return [nodeId]
return undefined
}, [nodeData.type, nodeId])
const allowStartNodeSelection = !hasStartNode
const availableNodes = useMemo(() => {
if (availablePrevBlocks.length && availableNextBlocks.length)
return intersection(availablePrevBlocks, availableNextBlocks)
if (availablePrevBlocks.length)
return availablePrevBlocks
return availableNextBlocks
}, [availablePrevBlocks, availableNextBlocks])
const handleSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => {
handleNodeChange(nodeId, type, sourceHandle, pluginDefaultValue)
}, [handleNodeChange, nodeId, sourceHandle])
const renderTrigger = useCallback(() => {
return (
<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' })}
</button>
)
}, [t])
return (
<BlockSelector
onSelect={handleSelect}
trigger={renderTrigger}
popupClassName="min-w-[240px]"
availableBlocksTypes={availableNodes}
showStartTab={showStartTab}
ignoreNodeIds={ignoreNodeIds}
forceEnableStartTab={nodeData.type === BlockEnum.Start}
allowUserInputSelection={allowStartNodeSelection}
/>
)
}

View File

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

View File

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

View 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>
)
}

View 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>
)
}

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

View File

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