diff --git a/web/app/components/workflow/__tests__/node-contextmenu.spec.tsx b/web/app/components/workflow/__tests__/node-contextmenu.spec.tsx index 7418b7f313..a34e655652 100644 --- a/web/app/components/workflow/__tests__/node-contextmenu.spec.tsx +++ b/web/app/components/workflow/__tests__/node-contextmenu.spec.tsx @@ -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 }) => ( +
+ {children} + +
+ ), + ContextMenuContent: ({ children, positionerProps, popupClassName }: { children: React.ReactNode, positionerProps?: { anchor?: unknown }, popupClassName?: string }) => { + mockContextMenuContent({ positionerProps, popupClassName }) + return
{children}
+ }, })) 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 ( - + + ), +})) + +vi.mock('@/app/components/workflow/hooks', async (importOriginal) => { + const actual = await importOriginal() + 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( + + open} /> + + + + , + { + 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) + 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) + mockUseNodesInteractions.mockReturnValue({ + handleNodeChange, + handleNodeDelete, + handleNodesDuplicate, + handleNodeSelect, + handleNodesCopy, + } as unknown as ReturnType) + mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false } as ReturnType) + mockUseNodesSyncDraft.mockReturnValue({ + doSyncWorkflowDraft: vi.fn(), + handleSyncWorkflowDraft, + syncWorkflowDraftWhenPageClose: vi.fn(), + } as ReturnType) + 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( + , + ) + + 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) + mockUseIsChatMode.mockReturnValueOnce(true) + mockUseHooksStore.mockImplementationOnce((selector: any) => selector({ configsMap: { flowType: FlowType.appFlow } })) + mockUseNodes.mockReturnValueOnce([] as any) + + const { rerender } = render( + , + ) + + 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) + 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( + , + ) + + 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) + + 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( + + open} /> + + + + , + { + nodes: [], + edges: [], + }, + ) + + expect(screen.getByRole('menuitem', { name: 'workflow.panel.openWorkflow' })).toHaveAttribute('href', '/app/app-123/workflow') + + mockUseNodesReadOnly.mockReturnValueOnce({ nodesReadOnly: true } as ReturnType) + mockUseNodeMetaData.mockReturnValueOnce({ + isTypeFixed: true, + isSingleton: true, + isUndeletable: true, + description: 'Read only node', + author: 'Dify', + } as ReturnType) + + rerender( + + open} /> + + + + , + ) + + expect(screen.queryByText('workflow.panel.runThisStep')).not.toBeInTheDocument() + expect(screen.queryByText('workflow.common.copy')).not.toBeInTheDocument() + expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/workflow/nodes/_base/components/panel-operator/__tests__/index.spec.tsx b/web/app/components/workflow/node-actions-menu/__tests__/index.spec.tsx similarity index 62% rename from web/app/components/workflow/nodes/_base/components/panel-operator/__tests__/index.spec.tsx rename to web/app/components/workflow/node-actions-menu/__tests__/index.spec.tsx index 6dab0f33a5..5624de9c16 100644 --- a/web/app/components/workflow/nodes/_base/components/panel-operator/__tests__/index.spec.tsx +++ b/web/app/components/workflow/node-actions-menu/__tests__/index.spec.tsx @@ -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() @@ -30,8 +30,8 @@ vi.mock('@/service/use-tools', () => ({ useAllWorkflowTools: vi.fn(), })) -vi.mock('../change-block', () => ({ - default: () =>
, +vi.mock('../change-block-menu-trigger', () => ({ + ChangeBlockMenuTrigger: () =>
, })) const mockUseNodeDataUpdate = vi.mocked(useNodeDataUpdate) @@ -73,18 +73,16 @@ const createQueryResult = (data: T): UseQueryResult => ({ const renderComponent = ( showHelpLink: boolean = true, onOpenChange?: (open: boolean) => void, - offset?: { mainAxis: number, crossAxis: number } | number, ) => renderWorkflowFlowComponent( - , @@ -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([])) }) - // 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() }) }) diff --git a/web/app/components/workflow/nodes/_base/components/panel-operator/change-block.tsx b/web/app/components/workflow/node-actions-menu/change-block-menu-trigger.tsx similarity index 82% rename from web/app/components/workflow/nodes/_base/components/panel-operator/change-block.tsx rename to web/app/components/workflow/node-actions-menu/change-block-menu-trigger.tsx index 7dcb7c1efa..183d515cf4 100644 --- a/web/app/components/workflow/nodes/_base/components/panel-operator/change-block.tsx +++ b/web/app/components/workflow/node-actions-menu/change-block-menu-trigger.tsx @@ -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((type, pluginDefaultValue) => { @@ -67,19 +62,17 @@ const ChangeBlock = ({ const renderTrigger = useCallback(() => { return ( -
+
+ ) }, [t]) return ( ) } - -export default memo(ChangeBlock) diff --git a/web/app/components/workflow/node-actions-menu/context-menu-content.tsx b/web/app/components/workflow/node-actions-menu/context-menu-content.tsx new file mode 100644 index 0000000000..8548450d59 --- /dev/null +++ b/web/app/components/workflow/node-actions-menu/context-menu-content.tsx @@ -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 && ( + + {model.canRun && ( + + {t('panel.runThisStep', { ns: 'workflow' })} + + )} + {model.canChangeBlock && ( + + )} + + )} + {hasRunGroup && (hasEditGroup || hasDeleteGroup || model.workflowAppHref || model.helpLinkUri) && } + {hasEditGroup && ( + + + + {t('common.copy', { ns: 'workflow' })} + + + + + {t('common.duplicate', { ns: 'workflow' })} + + + + )} + {hasEditGroup && (hasDeleteGroup || model.workflowAppHref || model.helpLinkUri) && } + {hasDeleteGroup && ( + + + + {t('operation.delete', { ns: 'common' })} + + + + )} + {hasDeleteGroup && (model.workflowAppHref || model.helpLinkUri) && } + {model.workflowAppHref && ( + + + {t('panel.openWorkflow', { ns: 'workflow' })} + + + )} + {model.workflowAppHref && model.helpLinkUri && } + {model.helpLinkUri && ( + + + {t('panel.helpLink', { ns: 'workflow' })} + + + )} + + + + ) +} diff --git a/web/app/components/workflow/node-actions-menu/dropdown-content.tsx b/web/app/components/workflow/node-actions-menu/dropdown-content.tsx new file mode 100644 index 0000000000..25d793c5f3 --- /dev/null +++ b/web/app/components/workflow/node-actions-menu/dropdown-content.tsx @@ -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 && ( + + {model.canRun && ( + + {t('panel.runThisStep', { ns: 'workflow' })} + + )} + {model.canChangeBlock && ( + + )} + + )} + {hasRunGroup && (hasEditGroup || hasDeleteGroup || model.workflowAppHref || model.helpLinkUri) && } + {hasEditGroup && ( + + + + {t('common.copy', { ns: 'workflow' })} + + + + + {t('common.duplicate', { ns: 'workflow' })} + + + + )} + {hasEditGroup && (hasDeleteGroup || model.workflowAppHref || model.helpLinkUri) && } + {hasDeleteGroup && ( + + + + {t('operation.delete', { ns: 'common' })} + + + + )} + {hasDeleteGroup && (model.workflowAppHref || model.helpLinkUri) && } + {model.workflowAppHref && ( + + + {t('panel.openWorkflow', { ns: 'workflow' })} + + + )} + {model.workflowAppHref && model.helpLinkUri && } + {model.helpLinkUri && ( + + + {t('panel.helpLink', { ns: 'workflow' })} + + + )} + + + + ) +} diff --git a/web/app/components/workflow/node-actions-menu/index.tsx b/web/app/components/workflow/node-actions-menu/index.tsx new file mode 100644 index 0000000000..3f813b93ba --- /dev/null +++ b/web/app/components/workflow/node-actions-menu/index.tsx @@ -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 ( + + + + + )} + /> + + + + + ) +} diff --git a/web/app/components/workflow/node-actions-menu/shared.tsx b/web/app/components/workflow/node-actions-menu/shared.tsx new file mode 100644 index 0000000000..a150025575 --- /dev/null +++ b/web/app/components/workflow/node-actions-menu/shared.tsx @@ -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 ( + <> + {children} + {(shortcut || hotkey) && } + + ) +} + +export function NodeActionsMenuAbout({ + author, + description, + title, +}: { + author?: string + description?: string + title: string +}) { + return ( +
+
+ {title.toLocaleUpperCase()} +
+
{description}
+
{author}
+
+ ) +} diff --git a/web/app/components/workflow/node-actions-menu/types.ts b/web/app/components/workflow/node-actions-menu/types.ts new file mode 100644 index 0000000000..565ccdcbca --- /dev/null +++ b/web/app/components/workflow/node-actions-menu/types.ts @@ -0,0 +1,8 @@ +import type { Node } from '@/app/components/workflow/types' + +export type NodeActionsMenuProps = { + id: string + data: Node['data'] + onClose: () => void + showHelpLink?: boolean +} diff --git a/web/app/components/workflow/node-actions-menu/use-node-actions-menu-model.ts b/web/app/components/workflow/node-actions-menu/use-node-actions-menu-model.ts new file mode 100644 index 0000000000..45e34e6bb2 --- /dev/null +++ b/web/app/components/workflow/node-actions-menu/use-node-actions-menu-model.ts @@ -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, + } +} diff --git a/web/app/components/workflow/node-contextmenu.tsx b/web/app/components/workflow/node-contextmenu.tsx index 27ef4029fc..e5bd7c933f 100644 --- a/web/app/components/workflow/node-contextmenu.tsx +++ b/web/app/components/workflow/node-contextmenu.tsx @@ -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 ( -
!open && handleNodeContextmenuCancel()} > - handleNodeContextmenuCancel()} - showHelpLink - /> -
+ + + + ) } - -export default memo(NodeContextmenu) diff --git a/web/app/components/workflow/nodes/_base/components/__tests__/node-control.spec.tsx b/web/app/components/workflow/nodes/_base/components/__tests__/node-control.spec.tsx index 82650a61f4..62d74b64da 100644 --- a/web/app/components/workflow/nodes/_base/components/__tests__/node-control.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/__tests__/node-control.spec.tsx @@ -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 }) => ( <> diff --git a/web/app/components/workflow/nodes/_base/components/node-control.tsx b/web/app/components/workflow/nodes/_base/components/node-control.tsx index 439e097bc9..5731ab27a7 100644 --- a/web/app/components/workflow/nodes/_base/components/node-control.tsx +++ b/web/app/components/workflow/nodes/_base/components/node-control.tsx @@ -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 & { pluginInstallLocked?: boolean @@ -82,10 +82,9 @@ const NodeControl: FC = ({ ) } -
diff --git a/web/app/components/workflow/nodes/_base/components/panel-operator/__tests__/details.spec.tsx b/web/app/components/workflow/nodes/_base/components/panel-operator/__tests__/details.spec.tsx deleted file mode 100644 index eeb6e48900..0000000000 --- a/web/app/components/workflow/nodes/_base/components/panel-operator/__tests__/details.spec.tsx +++ /dev/null @@ -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) => ( -
-
{trigger()}
-
{`available:${(availableBlocksTypes || []).join(',')}`}
-
{`show-start:${String(showStartTab)}`}
-
{`ignore:${(ignoreNodeIds || []).join(',')}`}
-
{`force-start:${String(forceEnableStartTab)}`}
-
{`allow-start:${String(allowUserInputSelection)}`}
- -
- ), -})) - -vi.mock('@/app/components/workflow/hooks', async (importOriginal) => { - const actual = await importOriginal() - 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) - 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) - mockUseNodesInteractions.mockReturnValue({ - handleNodeChange, - handleNodeDelete, - handleNodesDuplicate, - handleNodeSelect, - handleNodesCopy, - } as unknown as ReturnType) - mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false } as ReturnType) - mockUseNodesSyncDraft.mockReturnValue({ - doSyncWorkflowDraft: vi.fn(), - handleSyncWorkflowDraft, - syncWorkflowDraftWhenPageClose: vi.fn(), - } as ReturnType) - 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( - , - ) - - 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) - mockUseIsChatMode.mockReturnValueOnce(true) - mockUseHooksStore.mockImplementationOnce((selector: any) => selector({ configsMap: { flowType: FlowType.appFlow } })) - mockUseNodes.mockReturnValueOnce([] as any) - - const { rerender } = render( - , - ) - - 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) - 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( - , - ) - - 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( - , - { - 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) - - renderWorkflowFlowComponent( - , - { - 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( - , - { - nodes: [], - edges: [], - }, - ) - - expect(screen.getByRole('link', { name: 'workflow.panel.openWorkflow' })).toHaveAttribute('href', '/app/app-123/workflow') - - mockUseNodesReadOnly.mockReturnValueOnce({ nodesReadOnly: true } as ReturnType) - mockUseNodeMetaData.mockReturnValueOnce({ - isTypeFixed: true, - isSingleton: true, - isUndeletable: true, - description: 'Read only node', - author: 'Dify', - } as ReturnType) - - rerender( - , - ) - - expect(screen.queryByText('workflow.panel.runThisStep')).not.toBeInTheDocument() - expect(screen.queryByText('workflow.common.copy')).not.toBeInTheDocument() - expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument() - }) - }) -}) diff --git a/web/app/components/workflow/nodes/_base/components/panel-operator/index.tsx b/web/app/components/workflow/nodes/_base/components/panel-operator/index.tsx deleted file mode 100644 index ee16fd0c06..0000000000 --- a/web/app/components/workflow/nodes/_base/components/panel-operator/index.tsx +++ /dev/null @@ -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 ( - - } - 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, - )} - > - - - - setOpen(false)} - showHelpLink={showHelpLink} - /> - - - ) -} - -export default memo(PanelOperator) diff --git a/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx b/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx deleted file mode 100644 index 8a73d4be87..0000000000 --- a/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx +++ /dev/null @@ -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 ( -
- { - (showChangeBlock || canRunBySingle(data.type, isChildNode)) && ( - <> -
- { - canRunBySingle(data.type, isChildNode) && ( - - ) - } - { - showChangeBlock && ( - - ) - } -
-
- - ) - } - { - !nodesReadOnly && ( - <> - { - !nodeMetaData.isSingleton && ( - <> -
- - -
-
- - ) - } - { - !nodeMetaData.isUndeletable && ( - <> -
- -
-
- - ) - } - - ) - } - { - isWorkflowTool && workflowAppId && ( - <> - -
- - ) - } - { - showHelpLink && nodeMetaData.helpLinkUri && ( - <> - -
- - ) - } -
-
-
- {t('panel.about', { ns: 'workflow' }).toLocaleUpperCase()} -
-
{nodeMetaData.description}
-
- {t('panel.createdBy', { ns: 'workflow' })} - {' '} - {nodeMetaData.author} -
-
-
-
- ) -} - -export default memo(PanelOperatorPopup) diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/index.spec.tsx index ac920e4862..793e773e59 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/index.spec.tsx @@ -241,8 +241,8 @@ vi.mock('../next-step', () => ({ default: () =>
next-step
, })) -vi.mock('../panel-operator', () => ({ - default: () =>
panel-operator
, +vi.mock('@/app/components/workflow/node-actions-menu', () => ({ + NodeActionsDropdown: () =>
node-actions-menu
, })) vi.mock('../retry/retry-on-panel', () => ({ diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx index ee96563761..928e1e5ee7 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx @@ -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 = ({ ) } - +
{ ['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' }], diff --git a/web/app/components/workflow/store/workflow/node-slice.ts b/web/app/components/workflow/store/workflow/node-slice.ts index eb16388ef4..fc96635acb 100644 --- a/web/app/components/workflow/store/workflow/node-slice.ts +++ b/web/app/components/workflow/store/workflow/node-slice.ts @@ -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