-
-
-
{t('nodes.assigner.operations.title', { ns: 'workflow' })}
-
- {items.map(item => (
- !isOperationItem(item)
- ? (
-
- )
- : (
-
{
- onSelect(item)
- setOpen(false)
- }}
- >
-
- {t(`nodes.assigner.operations.${item.name}`, { ns: 'workflow' })}
-
- {item.value === value && (
-
-
-
- )}
+
+
+ {t('nodes.assigner.operations.title', { ns: 'workflow' })}
+ {items.map(item => (
+ !isOperationItem(item)
+ ? (
+
+ )
+ : (
+ onSelect(item)}
+ >
+
+ {t(`nodes.assigner.operations.${item.name}`, { ns: 'workflow' })}
- )
- ))}
-
-
-
-
+ {item.value === value && (
+
+
+
+ )}
+
+ )
+ ))}
+
+
+
)
}
diff --git a/web/app/components/workflow/operator/__tests__/more-actions.spec.tsx b/web/app/components/workflow/operator/__tests__/more-actions.spec.tsx
new file mode 100644
index 0000000000..c5aeabbaaa
--- /dev/null
+++ b/web/app/components/workflow/operator/__tests__/more-actions.spec.tsx
@@ -0,0 +1,309 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { act } from 'react'
+import MoreActions from '../more-actions'
+
+const mockToPng = vi.fn()
+const mockToJpeg = vi.fn()
+const mockToSvg = vi.fn()
+const mockDownloadUrl = vi.fn()
+const mockSetViewport = vi.fn()
+const mockGetNodesReadOnly = vi.fn()
+const {
+ mockAppStoreState,
+ mockWorkflowState,
+} = vi.hoisted(() => ({
+ mockAppStoreState: {
+ appSidebarExpand: 'collapse',
+ },
+ mockWorkflowState: {
+ knowledgeName: '',
+ appName: 'Demo App',
+ maximizeCanvas: false,
+ },
+}))
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key,
+ }),
+}))
+
+vi.mock('@/app/components/base/ui/dropdown-menu', async () => {
+ const React = await import('react')
+ const DropdownMenuContext = React.createContext<{ open: boolean, setOpen: (open: boolean) => void } | null>(null)
+
+ const useDropdownMenuContext = () => {
+ const context = React.use(DropdownMenuContext)
+ if (!context)
+ throw new Error('DropdownMenu components must be wrapped in DropdownMenu')
+ return context
+ }
+
+ return {
+ DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => (
+
+ {children}
+
+ ),
+ DropdownMenuTrigger: ({ children, className }: { children: React.ReactNode, className?: string }) => {
+ const { open, setOpen } = useDropdownMenuContext()
+ return (
+
+ )
+ },
+ DropdownMenuContent: ({ children }: { children: React.ReactNode }) => {
+ const { open } = useDropdownMenuContext()
+ return open ?
{children}
: null
+ },
+ DropdownMenuItem: ({
+ children,
+ onClick,
+ className,
+ }: {
+ children: React.ReactNode
+ onClick?: React.MouseEventHandler
+ className?: string
+ }) => {
+ const { setOpen } = useDropdownMenuContext()
+ return (
+
+ )
+ },
+ DropdownMenuSeparator: ({ className }: { className?: string }) => ,
+ }
+})
+
+vi.mock('html-to-image', () => ({
+ toPng: (...args: unknown[]) => mockToPng(...args),
+ toJpeg: (...args: unknown[]) => mockToJpeg(...args),
+ toSvg: (...args: unknown[]) => mockToSvg(...args),
+}))
+
+vi.mock('reactflow', () => ({
+ getNodesBounds: () => ({ x: 0, y: 0, width: 240, height: 120 }),
+ useReactFlow: () => ({
+ getNodes: () => [{ id: 'node-1' }],
+ getViewport: () => ({ x: 0, y: 0, zoom: 1 }),
+ setViewport: mockSetViewport,
+ }),
+}))
+
+vi.mock('@/app/components/app/store', () => ({
+ useStore: (selector: (state: typeof mockAppStoreState) => unknown) => selector(mockAppStoreState),
+}))
+
+vi.mock('@/app/components/workflow/store', () => ({
+ useStore: (selector: (state: typeof mockWorkflowState) => unknown) => selector(mockWorkflowState),
+}))
+
+vi.mock('@/app/components/workflow/hooks', () => ({
+ useNodesReadOnly: () => ({
+ getNodesReadOnly: mockGetNodesReadOnly,
+ }),
+}))
+
+vi.mock('@/utils/download', () => ({
+ downloadUrl: (...args: unknown[]) => mockDownloadUrl(...args),
+}))
+
+vi.mock('../tip-popup', () => ({
+ default: ({ children }: { children: React.ReactNode }) => <>{children}>,
+}))
+
+vi.mock('@/app/components/base/image-uploader/image-preview', () => ({
+ default: ({ title, onCancel }: { title: string, onCancel: () => void }) => (
+
+ {title}
+
+
+ ),
+}))
+
+describe('MoreActions', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.useRealTimers()
+ mockGetNodesReadOnly.mockReturnValue(false)
+ mockToPng.mockResolvedValue('data:image/png;base64,current')
+ mockToJpeg.mockResolvedValue('data:image/jpeg;base64,current')
+ mockToSvg.mockResolvedValue('data:image/svg+xml;base64,current')
+ mockAppStoreState.appSidebarExpand = 'collapse'
+ mockWorkflowState.knowledgeName = ''
+ mockWorkflowState.appName = 'Demo App'
+ mockWorkflowState.maximizeCanvas = false
+
+ document.body.innerHTML = ''
+ const viewport = document.createElement('div')
+ viewport.className = 'react-flow__viewport'
+ document.body.appendChild(viewport)
+ })
+
+ it('opens the menu and exports the current view as png', async () => {
+ const user = userEvent.setup()
+
+ render()
+
+ await user.click(screen.getByRole('button'))
+ await user.click(screen.getAllByText('workflow.common.exportPNG')[0])
+
+ await waitFor(() => {
+ expect(mockToPng).toHaveBeenCalledTimes(1)
+ })
+ expect(mockDownloadUrl).toHaveBeenCalledWith({
+ url: 'data:image/png;base64,current',
+ fileName: 'Demo App.png',
+ })
+ })
+
+ it('does not open the menu when the workflow is read only', async () => {
+ const user = userEvent.setup()
+ mockGetNodesReadOnly.mockReturnValue(true)
+
+ render()
+
+ await user.click(screen.getByRole('button'))
+
+ expect(screen.queryByText('workflow.common.exportImage')).not.toBeInTheDocument()
+ })
+
+ it('shows a preview when exporting the whole workflow', async () => {
+ vi.useFakeTimers()
+
+ render()
+
+ fireEvent.click(screen.getByRole('button'))
+ fireEvent.click(screen.getAllByText('workflow.common.exportPNG')[1])
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(300)
+ })
+
+ expect(screen.getByTestId('image-preview')).toHaveTextContent('Demo App-whole-workflow.png')
+ await act(async () => {
+ await vi.runAllTimersAsync()
+ })
+ expect(mockSetViewport).toHaveBeenCalledTimes(2)
+ expect(mockDownloadUrl).toHaveBeenCalledWith({
+ url: 'data:image/png;base64,current',
+ fileName: 'Demo App-whole-workflow.png',
+ })
+ })
+
+ it.each([
+ ['workflow.common.exportJPEG', mockToJpeg, 'Demo App.jpeg'],
+ ['workflow.common.exportSVG', mockToSvg, 'Demo App.svg'],
+ ])('exports the current view with %s', async (label, exporter, fileName) => {
+ const user = userEvent.setup()
+
+ render()
+
+ await user.click(screen.getByRole('button'))
+ await user.click(screen.getAllByText(label)[0])
+
+ await waitFor(() => {
+ expect(exporter).toHaveBeenCalledTimes(1)
+ })
+ expect(mockDownloadUrl).toHaveBeenCalledWith({
+ url: expect.any(String),
+ fileName,
+ })
+ })
+
+ it('exports the whole workflow as svg when the canvas is maximized', async () => {
+ vi.useFakeTimers()
+ mockWorkflowState.maximizeCanvas = true
+
+ render()
+
+ fireEvent.click(screen.getByRole('button'))
+ fireEvent.click(screen.getAllByText('workflow.common.exportSVG')[1])
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(300)
+ })
+
+ expect(mockToSvg).toHaveBeenCalledTimes(1)
+ await act(async () => {
+ await vi.runAllTimersAsync()
+ })
+ expect(mockSetViewport).toHaveBeenCalledTimes(2)
+ expect(screen.getByTestId('image-preview')).toHaveTextContent('Demo App-whole-workflow.svg')
+ })
+
+ it('returns early when there is no app or knowledge name', async () => {
+ const user = userEvent.setup()
+ mockWorkflowState.appName = ''
+ mockWorkflowState.knowledgeName = ''
+
+ render()
+
+ await user.click(screen.getByRole('button'))
+ await user.click(screen.getAllByText('workflow.common.exportPNG')[0])
+
+ expect(mockToPng).not.toHaveBeenCalled()
+ expect(mockDownloadUrl).not.toHaveBeenCalled()
+ })
+
+ it('returns early when the viewport element is missing', async () => {
+ const user = userEvent.setup()
+ document.querySelector('.react-flow__viewport')?.remove()
+
+ render()
+
+ await user.click(screen.getByRole('button'))
+ await user.click(screen.getAllByText('workflow.common.exportPNG')[0])
+
+ expect(mockToPng).not.toHaveBeenCalled()
+ expect(mockDownloadUrl).not.toHaveBeenCalled()
+ })
+
+ it('returns early when the workflow becomes read only before exporting', async () => {
+ const user = userEvent.setup()
+
+ render()
+
+ await user.click(screen.getByRole('button'))
+ mockGetNodesReadOnly.mockReturnValue(true)
+ await user.click(screen.getAllByText('workflow.common.exportJPEG')[0])
+
+ expect(mockToJpeg).not.toHaveBeenCalled()
+ expect(mockDownloadUrl).not.toHaveBeenCalled()
+ })
+
+ it('logs export failures and lets the preview close', async () => {
+ const user = userEvent.setup()
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
+ mockToJpeg.mockRejectedValueOnce(new Error('boom'))
+
+ render()
+
+ await user.click(screen.getByRole('button'))
+ await user.click(screen.getAllByText('workflow.common.exportJPEG')[0])
+
+ await waitFor(() => {
+ expect(consoleErrorSpy).toHaveBeenCalledWith('Export image failed:', expect.any(Error))
+ })
+ expect(screen.queryByTestId('image-preview')).not.toBeInTheDocument()
+
+ mockToPng.mockResolvedValueOnce('data:image/png;base64,current')
+ fireEvent.click(screen.getByRole('button'))
+ fireEvent.click(screen.getAllByText('workflow.common.exportPNG')[1])
+ await waitFor(() => {
+ expect(screen.getByTestId('image-preview')).toBeInTheDocument()
+ })
+ await user.click(screen.getByText('close-preview'))
+ expect(screen.queryByTestId('image-preview')).not.toBeInTheDocument()
+
+ consoleErrorSpy.mockRestore()
+ })
+})
diff --git a/web/app/components/workflow/operator/more-actions.tsx b/web/app/components/workflow/operator/more-actions.tsx
index 5e71cc658b..66dbed1a91 100644
--- a/web/app/components/workflow/operator/more-actions.tsx
+++ b/web/app/components/workflow/operator/more-actions.tsx
@@ -14,10 +14,12 @@ import { useShallow } from 'zustand/react/shallow'
import { useStore as useAppStore } from '@/app/components/app/store'
import ImagePreview from '@/app/components/base/image-uploader/image-preview'
import {
- PortalToFollowElem,
- PortalToFollowElemContent,
- PortalToFollowElemTrigger,
-} from '@/app/components/base/portal-to-follow-elem'
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/app/components/base/ui/dropdown-menu'
import { useStore } from '@/app/components/workflow/store'
import { downloadUrl } from '@/utils/download'
import { useNodesReadOnly } from '../hooks'
@@ -37,6 +39,7 @@ const MoreActions: FC = () => {
const { appSidebarExpand } = useAppStore(useShallow(state => ({
appSidebarExpand: state.appSidebarExpand,
})))
+ const isReadOnly = getNodesReadOnly()
const crossAxisOffset = useMemo(() => {
if (maximizeCanvas)
@@ -161,93 +164,67 @@ const MoreActions: FC = () => {
}
}, [getNodesReadOnly, appName, reactFlow, knowledgeName])
- const handleTrigger = useCallback(() => {
- if (getNodesReadOnly())
- return
-
- setOpen(v => !v)
- }, [getNodesReadOnly])
-
return (
<>
- {
+ if (isReadOnly) {
+ setOpen(false)
+ return
+ }
+ setOpen(nextOpen)
}}
>
-
+
-
-
-
+
-
-
-
-
-
-
- {t('common.exportImage', { ns: 'workflow' })}
-
-
- {t('common.currentView', { ns: 'workflow' })}
-
-
handleExportImage('png')}
- >
- {t('common.exportPNG', { ns: 'workflow' })}
-
-
handleExportImage('jpeg')}
- >
- {t('common.exportJPEG', { ns: 'workflow' })}
-
-
handleExportImage('svg')}
- >
- {t('common.exportSVG', { ns: 'workflow' })}
-
-
-
-
-
- {t('common.currentWorkflow', { ns: 'workflow' })}
-
-
handleExportImage('png', true)}
- >
- {t('common.exportPNG', { ns: 'workflow' })}
-
-
handleExportImage('jpeg', true)}
- >
- {t('common.exportJPEG', { ns: 'workflow' })}
-
-
handleExportImage('svg', true)}
- >
- {t('common.exportSVG', { ns: 'workflow' })}
-
-
+
+
+
+
+ {t('common.exportImage', { ns: 'workflow' })}
-
-
+
+ {t('common.currentView', { ns: 'workflow' })}
+
+ handleExportImage('png')}>
+ {t('common.exportPNG', { ns: 'workflow' })}
+
+ handleExportImage('jpeg')}>
+ {t('common.exportJPEG', { ns: 'workflow' })}
+
+ handleExportImage('svg')}>
+ {t('common.exportSVG', { ns: 'workflow' })}
+
+
+
+
+
+ {t('common.currentWorkflow', { ns: 'workflow' })}
+
+ handleExportImage('png', true)}>
+ {t('common.exportPNG', { ns: 'workflow' })}
+
+ handleExportImage('jpeg', true)}>
+ {t('common.exportJPEG', { ns: 'workflow' })}
+
+ handleExportImage('svg', true)}>
+ {t('common.exportSVG', { ns: 'workflow' })}
+
+
+
{previewUrl && (
{
const onClick = vi.fn()
render(
- ,
+
+
+
+
+ ,
)
await user.click(screen.getByText('Delete'))
diff --git a/web/app/components/workflow/panel/version-history-panel/context-menu/index.tsx b/web/app/components/workflow/panel/version-history-panel/context-menu/index.tsx
index a635e80bab..f063902753 100644
--- a/web/app/components/workflow/panel/version-history-panel/context-menu/index.tsx
+++ b/web/app/components/workflow/panel/version-history-panel/context-menu/index.tsx
@@ -1,14 +1,13 @@
import type { FC } from 'react'
import { RiMoreFill } from '@remixicon/react'
import * as React from 'react'
-import { useCallback } from 'react'
-import Divider from '@/app/components/base/divider'
-import {
- PortalToFollowElem,
- PortalToFollowElemContent,
- PortalToFollowElemTrigger,
-} from '@/app/components/base/portal-to-follow-elem'
import { Button } from '@/app/components/base/ui/button'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/app/components/base/ui/dropdown-menu'
import { VersionHistoryContextMenuOptions } from '../../../types'
import MenuItem from './menu-item'
import useContextMenu from './use-context-menu'
@@ -28,58 +27,44 @@ const ContextMenu: FC = (props: ContextMenuProps) => {
options,
} = useContextMenu(props)
- const handleClickTrigger = useCallback((e: React.MouseEvent) => {
- e.stopPropagation()
- setOpen(v => !v)
- }, [setOpen])
-
return (
-
-
-
-
-
-
-
- {
- options.map((option) => {
- return (
-
- )
- })
- }
-
- {
- isShowDelete && (
- <>
-
-
-
-
- >
- )
- }
-
-
-
+ e.stopPropagation()} />}
+ >
+
+
+
+ {
+ options.map(option => (
+
+ ))
+ }
+ {
+ isShowDelete && (
+ <>
+
+
+ >
+ )
+ }
+
+
)
}
diff --git a/web/app/components/workflow/panel/version-history-panel/context-menu/menu-item.tsx b/web/app/components/workflow/panel/version-history-panel/context-menu/menu-item.tsx
index 5a3f21272f..2c393dea77 100644
--- a/web/app/components/workflow/panel/version-history-panel/context-menu/menu-item.tsx
+++ b/web/app/components/workflow/panel/version-history-panel/context-menu/menu-item.tsx
@@ -2,6 +2,7 @@ import type { FC } from 'react'
import type { VersionHistoryContextMenuOptions } from '../../../types'
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
+import { DropdownMenuItem } from '@/app/components/base/ui/dropdown-menu'
type MenuItemProps = {
item: {
@@ -18,23 +19,25 @@ const MenuItem: FC = ({
isDestructive = false,
}) => {
return (
- {
+ destructive={isDestructive}
+ onClick={(event) => {
+ event.stopPropagation()
onClick(item.key)
}}
>
{item.name}
-
+
)
}
diff --git a/web/app/components/workflow/run/agent-log/__tests__/agent-log-nav-more.spec.tsx b/web/app/components/workflow/run/agent-log/__tests__/agent-log-nav-more.spec.tsx
index d109635af4..35b32a5ce6 100644
--- a/web/app/components/workflow/run/agent-log/__tests__/agent-log-nav-more.spec.tsx
+++ b/web/app/components/workflow/run/agent-log/__tests__/agent-log-nav-more.spec.tsx
@@ -3,6 +3,52 @@ import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import AgentLogNavMore from '../agent-log-nav-more'
+vi.mock('@/app/components/base/ui/dropdown-menu', async () => {
+ const React = await import('react')
+ const DropdownMenuContext = React.createContext<{ open: boolean, setOpen: (open: boolean) => void } | null>(null)
+
+ const useDropdownMenuContext = () => {
+ const context = React.use(DropdownMenuContext)
+ if (!context)
+ throw new Error('DropdownMenu components must be wrapped in DropdownMenu')
+ return context
+ }
+
+ return {
+ DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => (
+
+ {children}
+
+ ),
+ DropdownMenuTrigger: ({ children, render }: { children: React.ReactNode, render?: React.ReactElement }) => {
+ const { open, setOpen } = useDropdownMenuContext()
+
+ if (render)
+ return React.cloneElement(render, { onClick: () => setOpen(!open) } as Record, children)
+
+ return
+ },
+ DropdownMenuContent: ({ children }: { children: React.ReactNode }) => {
+ const { open } = useDropdownMenuContext()
+ return open ? {children}
: null
+ },
+ DropdownMenuItem: ({ children, onClick }: { children: React.ReactNode, onClick?: React.MouseEventHandler }) => {
+ const { setOpen } = useDropdownMenuContext()
+ return (
+
+ )
+ },
+ }
+})
+
const createLogItem = (overrides: Partial = {}): AgentLogItemWithChildren => ({
message_id: 'message-1',
label: 'Planner',
diff --git a/web/app/components/workflow/run/agent-log/agent-log-nav-more.tsx b/web/app/components/workflow/run/agent-log/agent-log-nav-more.tsx
index 8bdb6ad227..77f3c778a6 100644
--- a/web/app/components/workflow/run/agent-log/agent-log-nav-more.tsx
+++ b/web/app/components/workflow/run/agent-log/agent-log-nav-more.tsx
@@ -1,12 +1,13 @@
import type { AgentLogItemWithChildren } from '@/types/workflow'
import { RiMoreLine } from '@remixicon/react'
import { useState } from 'react'
-import {
- PortalToFollowElem,
- PortalToFollowElemContent,
- PortalToFollowElemTrigger,
-} from '@/app/components/base/portal-to-follow-elem'
import { Button } from '@/app/components/base/ui/button'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/app/components/base/ui/dropdown-menu'
type AgentLogNavMoreProps = {
options: AgentLogItemWithChildren[]
@@ -19,42 +20,39 @@ const AgentLogNavMore = ({
const [open, setOpen] = useState(false)
return (
-
- setOpen(v => !v)}>
-
-
-
-
- {
- options.map(option => (
-
{
- onShowAgentOrToolLog(option)
- setOpen(false)
- }}
- >
- {option.label}
-
- ))
- }
-
-
-
+
+ )}
+ >
+
+
+
+ {
+ options.map(option => (
+ onShowAgentOrToolLog(option)}
+ >
+ {option.label}
+
+ ))
+ }
+
+
)
}