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' })}
+
+
+
+
+
+
+
+
)}
)}