diff --git a/packages/dify-ui/src/popover/__tests__/index.spec.tsx b/packages/dify-ui/src/popover/__tests__/index.spec.tsx index 576c7d338c9..08fc3f38ad1 100644 --- a/packages/dify-ui/src/popover/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/popover/__tests__/index.spec.tsx @@ -3,6 +3,8 @@ import { userEvent } from 'vite-plus/test/browser' import { render } from 'vitest-browser-react' import { Popover, + PopoverBackdrop, + PopoverClose, PopoverContent, PopoverDescription, PopoverPopup, @@ -11,6 +13,12 @@ import { PopoverTitle, PopoverTrigger, } from '..' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '../../dropdown-menu' const renderWithSafeViewport = (ui: React.ReactNode) => render(
{ui}
) @@ -95,6 +103,47 @@ describe('PopoverContent', () => { }) describe('Popover anatomy', () => { + it('places the backdrop above a parent menu and below the interactive popup', async () => { + const backdropRef = React.createRef() + const screen = await renderWithSafeViewport( + + Actions + + Primary action + + } />} + > + More options + + + + + + More options + + Close + + + + + + , + ) + + await screen.getByRole('button', { name: 'Actions' }).click() + const primaryAction = screen.getByRole('menuitem', { name: 'Primary action' }).element() + await screen.getByRole('menuitem', { name: 'More options' }).click() + const input = screen.getByRole('textbox', { name: 'Filter options' }) + + const { x, y, width, height } = primaryAction.getBoundingClientRect() + expect(document.elementFromPoint(x + width / 2, y + height / 2)).toBe(backdropRef.current) + await input.click() + await input.fill('Example') + await expect.element(input).toHaveValue('Example') + }) + it('should use the default positioner placement', async () => { const screen = await renderWithSafeViewport( diff --git a/packages/dify-ui/src/popover/index.tsx b/packages/dify-ui/src/popover/index.tsx index 06f8ed2d67a..9bbd0ab2d85 100644 --- a/packages/dify-ui/src/popover/index.tsx +++ b/packages/dify-ui/src/popover/index.tsx @@ -25,6 +25,14 @@ type PopoverCloseProps = BasePopover.Close.Props type PopoverTitleProps = BasePopover.Title.Props type PopoverDescriptionProps = BasePopover.Description.Props +type PopoverBackdropProps = Omit & { + className?: string +} + +function PopoverBackdrop({ className, ...props }: PopoverBackdropProps) { + return +} + type PopoverPositionerProps = Omit & { className?: string placement?: Placement @@ -103,6 +111,7 @@ export { createPopoverHandle, Popover, PopoverArrow, + PopoverBackdrop, PopoverClose, PopoverContent, PopoverDescription, @@ -114,6 +123,7 @@ export { } export type { PopoverArrowProps, + PopoverBackdropProps, PopoverCloseProps, PopoverContentProps, PopoverDescriptionProps, diff --git a/web/app/components/workflow/block-selector/index.tsx b/web/app/components/workflow/block-selector/index.tsx index 3a91e2926ac..84b2f0fd9f7 100644 --- a/web/app/components/workflow/block-selector/index.tsx +++ b/web/app/components/workflow/block-selector/index.tsx @@ -241,7 +241,7 @@ type BlockSelectorContentProps = Pick< searchInputRef: React.RefObject } -function BlockSelectorContent({ +export function BlockSelectorContent({ allowUserInputSelection, availableBlocksTypes, blocks: blocksFromProps, diff --git a/web/app/components/workflow/node-actions-menu/__tests__/details.spec.tsx b/web/app/components/workflow/node-actions-menu/__tests__/details.spec.tsx index 6bf60b9b36e..05f3ac93a29 100644 --- a/web/app/components/workflow/node-actions-menu/__tests__/details.spec.tsx +++ b/web/app/components/workflow/node-actions-menu/__tests__/details.spec.tsx @@ -3,7 +3,7 @@ import { DropdownMenuContent, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' -import { screen } from '@testing-library/react' +import { screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env' import { useHooksStore } from '@/app/components/workflow/hooks-store' @@ -11,11 +11,13 @@ import useNodes from '@/app/components/workflow/store/workflow/use-nodes' import { BlockEnum, NodeRunningStatus } from '@/app/components/workflow/types' import { useAllWorkflowTools } from '@/service/use-tools' import { FlowType } from '@/types/common' +import { BlockClassification } from '../../block-selector/types' import { useAvailableBlocks } from '../../hooks/use-available-blocks' import { useNodesInteractions } from '../../hooks/use-nodes-interactions' import { useNodeMetaData } from '../../hooks/use-nodes-meta-data' import { useIsChatMode, useNodesReadOnly } from '../../hooks/use-workflow' import { NodeActionsDropdownContent } from '../dropdown-content' +import { NodeActionsDropdown } from '../index' vi.mock('../../hooks/use-available-blocks', async (importOriginal) => { const actual = await importOriginal() @@ -64,6 +66,22 @@ vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({ vi.mock('@/service/use-tools', () => ({ useAllWorkflowTools: vi.fn(), + useAllBuiltInTools: () => ({ data: [] }), + useAllCustomTools: () => ({ data: [] }), + useAllMCPTools: () => ({ data: [] }), + useInvalidateAllBuiltInTools: () => vi.fn(), +})) + +vi.mock('@/service/use-plugins', () => ({ + useFeaturedToolsRecommendations: () => ({ plugins: [], isLoading: false }), + useFeaturedTriggersRecommendations: () => ({ plugins: [], isLoading: false }), +})) +vi.mock('@/app/components/plugins/marketplace/query', () => ({ + useMarketplacePlugins: () => ({ data: undefined }), +})) +vi.mock('@/service/use-triggers', () => ({ + useAllTriggerPlugins: () => ({ data: [] }), + useInvalidateAllTriggerPlugins: () => vi.fn(), })) const mockUseAvailableBlocks = vi.mocked(useAvailableBlocks) @@ -143,6 +161,22 @@ describe('node actions menu details', () => { selector({ configsMap: { flowType: FlowType.appFlow }, accessControl: { canRun: true }, + availableNodesMetaData: { + nodes: [ + { + metaData: { + type: BlockEnum.HttpRequest, + title: 'HTTP Request', + classification: BlockClassification.Default, + sort: 0, + author: 'Dify', + description: 'Send a request', + }, + defaultValue: {}, + checkValid: () => ({ isValid: true }), + }, + ], + }, }), ) mockUseNodes.mockReturnValue([ @@ -151,6 +185,46 @@ describe('node actions menu details', () => { mockUseAllWorkflowTools.mockReturnValue({ data: [] } as any) }) + it('keeps the menu after Escape and closes both surfaces after changing a node', async () => { + const user = userEvent.setup() + renderWorkflowFlowComponent( + , + { + nodes: [], + edges: [{ id: 'edge-1', source: 'node-0', target: 'node-1', sourceHandle: 'branch-a' }], + }, + ) + + await user.click(screen.getByRole('button', { name: 'common.operation.more' })) + const changeNode = screen.getByRole('menuitem', { name: 'workflow.panel.changeBlock' }) + await user.click(changeNode) + const search = screen.getByRole('searchbox', { name: 'workflow.tabs.searchBlock' }) + await waitFor(() => expect(search).toHaveFocus()) + await user.keyboard('{ArrowDown}') + expect(search).toHaveFocus() + await user.keyboard('{Escape}') + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + expect(screen.getByRole('menu')).toBeInTheDocument() + expect(changeNode).toHaveFocus() + + await user.keyboard('{Enter}') + await user.click(await screen.findByRole('button', { name: 'HTTP Request' })) + + expect(handleNodeChange).toHaveBeenCalledExactlyOnceWith( + 'node-1', + BlockEnum.HttpRequest, + 'branch-a', + undefined, + ) + await waitFor(() => { + expect(screen.queryByRole('menu')).not.toBeInTheDocument() + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + }) + it('should run, copy, duplicate, delete, and expose the help link', async () => { const user = userEvent.setup() const { store } = renderDropdownContent() diff --git a/web/app/components/workflow/node-actions-menu/__tests__/index.spec.tsx b/web/app/components/workflow/node-actions-menu/__tests__/index.spec.tsx index d5a595881ff..4003a0f3f21 100644 --- a/web/app/components/workflow/node-actions-menu/__tests__/index.spec.tsx +++ b/web/app/components/workflow/node-actions-menu/__tests__/index.spec.tsx @@ -41,8 +41,8 @@ vi.mock('@/service/use-tools', () => ({ useAllWorkflowTools: vi.fn(), })) -vi.mock('../change-block-menu-trigger', () => ({ - ChangeBlockMenuTrigger: () =>
, +vi.mock('../change-block-popup', () => ({ + ChangeBlockPopup: () =>
, })) const mockUseNodeMetaData = vi.mocked(useNodeMetaData) diff --git a/web/app/components/workflow/node-actions-menu/change-block-menu-trigger.tsx b/web/app/components/workflow/node-actions-menu/change-block-popup.tsx similarity index 58% rename from web/app/components/workflow/node-actions-menu/change-block-menu-trigger.tsx rename to web/app/components/workflow/node-actions-menu/change-block-popup.tsx index 672e60afec1..c911cbdc98b 100644 --- a/web/app/components/workflow/node-actions-menu/change-block-menu-trigger.tsx +++ b/web/app/components/workflow/node-actions-menu/change-block-popup.tsx @@ -1,8 +1,9 @@ import type { CommonNodeType, Node, OnSelectBlock } from '@/app/components/workflow/types' +import { PopoverClose, PopoverPopup, PopoverTitle } from '@langgenius/dify-ui/popover' import { intersection } from 'es-toolkit/array' -import { useCallback, useMemo } from 'react' +import { useCallback, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' -import BlockSelector from '@/app/components/workflow/block-selector' +import { BlockSelectorContent } from '@/app/components/workflow/block-selector' 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' @@ -12,18 +13,22 @@ import { useAvailableBlocks } from '../hooks/use-available-blocks' import { useNodesInteractions } from '../hooks/use-nodes-interactions' import { useIsChatMode } from '../hooks/use-workflow' -type ChangeBlockMenuTriggerProps = { +type ChangeBlockPopupProps = { nodeId: string nodeData: Node['data'] sourceHandle: string + onComplete: () => void } -export function ChangeBlockMenuTrigger({ +export function ChangeBlockPopup({ nodeId, nodeData, sourceHandle, -}: ChangeBlockMenuTriggerProps) { + onComplete, +}: ChangeBlockPopupProps) { const { t } = useTranslation() + const searchInputRef = useRef(null) + const completedRef = useRef(false) const { handleNodeChange } = useNodesInteractions() const nodeCatalogType = getNodeCatalogType(nodeData) const { availablePrevBlocks, availableNextBlocks } = useAvailableBlocks( @@ -53,33 +58,42 @@ export function ChangeBlockMenuTrigger({ return availableNextBlocks }, [availablePrevBlocks, availableNextBlocks]) + const handleComplete = useCallback(() => { + completedRef.current = true + onComplete() + }, [onComplete]) + const handleSelect = useCallback( (type, pluginDefaultValue) => { + handleComplete() handleNodeChange(nodeId, type, sourceHandle, pluginDefaultValue) }, - [handleNodeChange, nodeId, sourceHandle], - ) - - const triggerElement = ( - + [handleNodeChange, nodeId, sourceHandle, handleComplete], ) return ( - + !completedRef.current} + className="w-100 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg" + onKeyDown={(event) => event.stopPropagation()} + > + + {t(($) => $['panel.changeBlock'], { ns: 'workflow' })} + + + + {t(($) => $['operation.close'], { ns: 'common' })} + + ) } 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 index 757c4b89692..7c44fbf8fa5 100644 --- a/web/app/components/workflow/node-actions-menu/context-menu-content.tsx +++ b/web/app/components/workflow/node-actions-menu/context-menu-content.tsx @@ -5,8 +5,15 @@ import { ContextMenuLinkItem, ContextMenuSeparator, } from '@langgenius/dify-ui/context-menu' +import { + Popover, + PopoverBackdrop, + PopoverPortal, + PopoverPositioner, + PopoverTrigger, +} from '@langgenius/dify-ui/popover' import { useTranslation } from 'react-i18next' -import { ChangeBlockMenuTrigger } from './change-block-menu-trigger' +import { ChangeBlockPopup } from './change-block-popup' import { NODE_ACTIONS_MENU_DELETE_ITEM_CLASS_NAME, NODE_ACTIONS_MENU_ITEM_WITH_SHORTCUT_CLASS_NAME, @@ -33,11 +40,26 @@ export function NodeActionsContextMenuContent(props: NodeActionsMenuProps) { {singleRunActionLabel} )} {model.canChangeBlock && ( - + + } />} + className="data-popup-open:bg-state-base-hover" + > + {t(($) => $['panel.changeBlock'], { 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 index 2603c805361..25805dc3ce4 100644 --- a/web/app/components/workflow/node-actions-menu/dropdown-content.tsx +++ b/web/app/components/workflow/node-actions-menu/dropdown-content.tsx @@ -5,8 +5,15 @@ import { DropdownMenuLinkItem, DropdownMenuSeparator, } from '@langgenius/dify-ui/dropdown-menu' +import { + Popover, + PopoverBackdrop, + PopoverPortal, + PopoverPositioner, + PopoverTrigger, +} from '@langgenius/dify-ui/popover' import { useTranslation } from 'react-i18next' -import { ChangeBlockMenuTrigger } from './change-block-menu-trigger' +import { ChangeBlockPopup } from './change-block-popup' import { NODE_ACTIONS_MENU_DELETE_ITEM_CLASS_NAME, NODE_ACTIONS_MENU_ITEM_WITH_SHORTCUT_CLASS_NAME, @@ -33,11 +40,26 @@ export function NodeActionsDropdownContent(props: NodeActionsMenuProps) { {singleRunActionLabel} )} {model.canChangeBlock && ( - + + } />} + className="data-popup-open:bg-state-base-hover" + > + {t(($) => $['panel.changeBlock'], { ns: 'workflow' })} + + + + + + + + )} )}