mirror of
https://github.com/langgenius/dify.git
synced 2026-05-05 18:00:59 -04:00
refactor(web): migrate workflow panel context menu primitive (#35787)
This commit is contained in:
@@ -1,107 +1,63 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import PanelContextmenu from '../panel-contextmenu'
|
||||
import { BlockEnum } from '../types'
|
||||
import { createNode } from './fixtures'
|
||||
import { renderWorkflowFlowComponent } from './workflow-test-env'
|
||||
|
||||
const mockUseClickAway = vi.hoisted(() => vi.fn())
|
||||
const mockUseTranslation = vi.hoisted(() => vi.fn())
|
||||
const mockUseStore = vi.hoisted(() => vi.fn())
|
||||
const mockUseNodesInteractions = vi.hoisted(() => vi.fn())
|
||||
const mockUsePanelInteractions = vi.hoisted(() => vi.fn())
|
||||
const mockUseWorkflowStartRun = vi.hoisted(() => vi.fn())
|
||||
const mockUseWorkflowMoveMode = vi.hoisted(() => vi.fn())
|
||||
const mockUseOperator = vi.hoisted(() => vi.fn())
|
||||
const mockUseDSL = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useClickAway: (...args: unknown[]) => mockUseClickAway(...args),
|
||||
}))
|
||||
const mockUseNodesReadOnly = vi.hoisted(() => vi.fn())
|
||||
const mockUseAvailableBlocks = vi.hoisted(() => vi.fn())
|
||||
const mockUseNodesMetaData = vi.hoisted(() => vi.fn())
|
||||
const mockUseIsChatMode = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => mockUseTranslation(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useStore: (selector: (state: {
|
||||
panelMenu?: { left: number, top: number }
|
||||
clipboardElements: unknown[]
|
||||
pendingComment: null | { pageX: number, pageY: number, elementX: number, elementY: number }
|
||||
setCommentPlacing: (placing: boolean) => void
|
||||
setCommentQuickAdd: (quickAdd: boolean) => void
|
||||
setShowImportDSLModal: (visible: boolean) => void
|
||||
}) => unknown) => mockUseStore(selector),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useNodesInteractions: () => mockUseNodesInteractions(),
|
||||
usePanelInteractions: () => mockUsePanelInteractions(),
|
||||
useWorkflowStartRun: () => mockUseWorkflowStartRun(),
|
||||
useWorkflowMoveMode: () => mockUseWorkflowMoveMode(),
|
||||
useAvailableBlocks: () => mockUseAvailableBlocks(),
|
||||
useDSL: () => mockUseDSL(),
|
||||
useIsChatMode: () => mockUseIsChatMode(),
|
||||
useNodesInteractions: () => mockUseNodesInteractions(),
|
||||
useNodesMetaData: () => mockUseNodesMetaData(),
|
||||
useNodesReadOnly: () => mockUseNodesReadOnly(),
|
||||
usePanelInteractions: () => mockUsePanelInteractions(),
|
||||
useWorkflowMoveMode: () => mockUseWorkflowMoveMode(),
|
||||
useWorkflowStartRun: () => mockUseWorkflowStartRun(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/operator/hooks', () => ({
|
||||
useOperator: () => mockUseOperator(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/operator/add-block', () => ({
|
||||
__esModule: true,
|
||||
default: ({ renderTrigger }: { renderTrigger: () => ReactNode }) => (
|
||||
<div data-testid="add-block">{renderTrigger()}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/divider', () => ({
|
||||
__esModule: true,
|
||||
default: ({ className }: { className?: string }) => <div data-testid="divider" className={className} />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/shortcuts-name', () => ({
|
||||
__esModule: true,
|
||||
default: ({ keys }: { keys: string[] }) => <span data-testid={`shortcut-${keys.join('-')}`}>{keys.join('+')}</span>,
|
||||
}))
|
||||
|
||||
describe('PanelContextmenu', () => {
|
||||
const mockHandleNodesPaste = vi.fn()
|
||||
const mockHandlePaneContextmenuCancel = vi.fn()
|
||||
const mockHandleStartWorkflowRun = vi.fn()
|
||||
const mockHandleWorkflowStartRunInChatflow = vi.fn()
|
||||
const mockHandleAddNote = vi.fn()
|
||||
const mockExportCheck = vi.fn()
|
||||
const mockSetShowImportDSLModal = vi.fn()
|
||||
const mockSetCommentPlacing = vi.fn()
|
||||
const mockSetCommentQuickAdd = vi.fn()
|
||||
let panelMenu: { left: number, top: number } | undefined
|
||||
let clipboardElements: unknown[]
|
||||
let pendingComment: null | { pageX: number, pageY: number, elementX: number, elementY: number }
|
||||
let clickAwayHandler: (() => void) | undefined
|
||||
const defaultNodesMetaDataMap = {
|
||||
[BlockEnum.Answer]: {
|
||||
defaultValue: {
|
||||
title: 'Answer',
|
||||
desc: '',
|
||||
type: BlockEnum.Answer,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
panelMenu = undefined
|
||||
clipboardElements = []
|
||||
pendingComment = null
|
||||
clickAwayHandler = undefined
|
||||
|
||||
mockUseClickAway.mockImplementation((handler: () => void) => {
|
||||
clickAwayHandler = handler
|
||||
})
|
||||
mockUseTranslation.mockReturnValue({
|
||||
t: (key: string) => key,
|
||||
})
|
||||
mockUseStore.mockImplementation((selector: (state: {
|
||||
panelMenu?: { left: number, top: number }
|
||||
clipboardElements: unknown[]
|
||||
pendingComment: null | { pageX: number, pageY: number, elementX: number, elementY: number }
|
||||
setCommentPlacing: (placing: boolean) => void
|
||||
setCommentQuickAdd: (quickAdd: boolean) => void
|
||||
setShowImportDSLModal: (visible: boolean) => void
|
||||
}) => unknown) => selector({
|
||||
panelMenu,
|
||||
clipboardElements,
|
||||
pendingComment,
|
||||
setCommentPlacing: mockSetCommentPlacing,
|
||||
setCommentQuickAdd: mockSetCommentQuickAdd,
|
||||
setShowImportDSLModal: mockSetShowImportDSLModal,
|
||||
}))
|
||||
mockUseNodesInteractions.mockReturnValue({
|
||||
handleNodesPaste: mockHandleNodesPaste,
|
||||
})
|
||||
@@ -110,6 +66,7 @@ describe('PanelContextmenu', () => {
|
||||
})
|
||||
mockUseWorkflowStartRun.mockReturnValue({
|
||||
handleStartWorkflowRun: mockHandleStartWorkflowRun,
|
||||
handleWorkflowStartRunInChatflow: mockHandleWorkflowStartRunInChatflow,
|
||||
})
|
||||
mockUseWorkflowMoveMode.mockReturnValue({
|
||||
isCommentModeAvailable: false,
|
||||
@@ -120,50 +77,86 @@ describe('PanelContextmenu', () => {
|
||||
mockUseDSL.mockReturnValue({
|
||||
exportCheck: mockExportCheck,
|
||||
})
|
||||
mockUseNodesReadOnly.mockReturnValue({
|
||||
nodesReadOnly: false,
|
||||
})
|
||||
mockUseAvailableBlocks.mockReturnValue({
|
||||
availableNextBlocks: [BlockEnum.Answer],
|
||||
})
|
||||
mockUseNodesMetaData.mockReturnValue({
|
||||
nodesMap: defaultNodesMetaDataMap,
|
||||
})
|
||||
mockUseIsChatMode.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('should stay hidden when the panel menu is absent', () => {
|
||||
render(<PanelContextmenu />)
|
||||
renderWorkflowFlowComponent(<PanelContextmenu />)
|
||||
|
||||
expect(screen.queryByTestId('add-block')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('common.addBlock')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep paste disabled when the clipboard is empty', () => {
|
||||
panelMenu = { left: 24, top: 48 }
|
||||
|
||||
render(<PanelContextmenu />)
|
||||
it('should keep paste disabled when the clipboard is empty', async () => {
|
||||
renderWorkflowFlowComponent(<PanelContextmenu />, {
|
||||
initialStoreState: {
|
||||
panelMenu: { clientX: 24, clientY: 48 },
|
||||
},
|
||||
hooksStoreProps: {},
|
||||
})
|
||||
|
||||
await screen.findByText('common.pasteHere')
|
||||
fireEvent.click(screen.getByText('common.pasteHere'))
|
||||
|
||||
expect(mockHandleNodesPaste).not.toHaveBeenCalled()
|
||||
expect(mockHandlePaneContextmenuCancel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render actions, position the menu, and execute each action', () => {
|
||||
panelMenu = { left: 24, top: 48 }
|
||||
clipboardElements = [{ id: 'copied-node' }]
|
||||
const { container } = render(<PanelContextmenu />)
|
||||
|
||||
expect(screen.getByTestId('add-block')).toHaveTextContent('common.addBlock')
|
||||
expect(screen.getByRole('button', { name: /common\.run/i })).toHaveTextContent(/Alt\s*R/)
|
||||
expect(screen.getByRole('button', { name: /common\.pasteHere/i })).toHaveTextContent(/Ctrl\s*V/)
|
||||
expect(container.firstChild).toHaveStyle({
|
||||
left: '24px',
|
||||
top: '48px',
|
||||
it('should render actions and execute enabled actions', async () => {
|
||||
const { store } = renderWorkflowFlowComponent(<PanelContextmenu />, {
|
||||
initialStoreState: {
|
||||
panelMenu: { clientX: 24, clientY: 48 },
|
||||
clipboardElements: [createNode({ id: 'copied-node' })],
|
||||
},
|
||||
hooksStoreProps: {},
|
||||
})
|
||||
|
||||
expect(await screen.findByText('common.addBlock')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.run')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.pasteHere')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('nodes.note.addNote'))
|
||||
fireEvent.click(screen.getByText('common.run'))
|
||||
fireEvent.click(screen.getByText('common.pasteHere'))
|
||||
fireEvent.click(screen.getByText('export'))
|
||||
fireEvent.click(screen.getByText('importApp'))
|
||||
clickAwayHandler?.()
|
||||
|
||||
expect(mockHandleAddNote).toHaveBeenCalledTimes(1)
|
||||
expect(mockHandleStartWorkflowRun).toHaveBeenCalledTimes(1)
|
||||
expect(mockHandleNodesPaste).toHaveBeenCalledTimes(1)
|
||||
expect(mockExportCheck).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetShowImportDSLModal).toHaveBeenCalledWith(true)
|
||||
expect(mockHandlePaneContextmenuCancel).toHaveBeenCalledTimes(4)
|
||||
await waitFor(() => {
|
||||
expect(mockHandleAddNote).toHaveBeenCalledTimes(1)
|
||||
expect(mockHandleStartWorkflowRun).toHaveBeenCalledTimes(1)
|
||||
expect(mockHandleNodesPaste).toHaveBeenCalledTimes(1)
|
||||
expect(mockExportCheck).toHaveBeenCalledTimes(1)
|
||||
expect(store.getState().showImportDSLModal).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('should render preview action in chat mode', async () => {
|
||||
mockUseIsChatMode.mockReturnValue(true)
|
||||
|
||||
renderWorkflowFlowComponent(<PanelContextmenu />, {
|
||||
initialStoreState: {
|
||||
panelMenu: { clientX: 24, clientY: 48 },
|
||||
},
|
||||
hooksStoreProps: {},
|
||||
})
|
||||
|
||||
expect(await screen.findByText('common.debugAndPreview')).toBeInTheDocument()
|
||||
expect(screen.queryByText('common.run')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('common.debugAndPreview'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHandleWorkflowStartRunInChatflow).toHaveBeenCalledTimes(1)
|
||||
expect(mockHandleStartWorkflowRun).not.toHaveBeenCalled()
|
||||
expect(mockHandlePaneContextmenuCancel).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -36,7 +36,7 @@ describe('usePanelInteractions', () => {
|
||||
container.remove()
|
||||
})
|
||||
|
||||
it('handlePaneContextMenu should set panelMenu with computed coordinates when container exists', () => {
|
||||
it('handlePaneContextMenu should set panelMenu with viewport coordinates', () => {
|
||||
const { result, store } = renderWorkflowHook(() => usePanelInteractions(), {
|
||||
initialStoreState: {
|
||||
nodeMenu: { clientX: 40, clientY: 20, nodeId: 'n1' },
|
||||
@@ -54,28 +54,14 @@ describe('usePanelInteractions', () => {
|
||||
|
||||
expect(preventDefault).toHaveBeenCalled()
|
||||
expect(store.getState().panelMenu).toEqual({
|
||||
top: 200,
|
||||
left: 250,
|
||||
clientX: 350,
|
||||
clientY: 250,
|
||||
})
|
||||
expect(store.getState().nodeMenu).toBeUndefined()
|
||||
expect(store.getState().selectionMenu).toBeUndefined()
|
||||
expect(store.getState().edgeMenu).toBeUndefined()
|
||||
})
|
||||
|
||||
it('handlePaneContextMenu should throw when container does not exist', () => {
|
||||
container.remove()
|
||||
|
||||
const { result } = renderWorkflowHook(() => usePanelInteractions())
|
||||
|
||||
expect(() => {
|
||||
result.current.handlePaneContextMenu({
|
||||
preventDefault: vi.fn(),
|
||||
clientX: 350,
|
||||
clientY: 250,
|
||||
} as unknown as React.MouseEvent)
|
||||
}).toThrow()
|
||||
})
|
||||
|
||||
it('handlePaneContextMenu should sync clipboard from navigator clipboard', async () => {
|
||||
const clipboardNode = createNode({ id: 'clipboard-node' })
|
||||
const clipboardEdge = createEdge({
|
||||
@@ -106,7 +92,7 @@ describe('usePanelInteractions', () => {
|
||||
|
||||
it('handlePaneContextmenuCancel should clear panelMenu', () => {
|
||||
const { result, store } = renderWorkflowHook(() => usePanelInteractions(), {
|
||||
initialStoreState: { panelMenu: { top: 10, left: 20 } },
|
||||
initialStoreState: { panelMenu: { clientX: 20, clientY: 10 } },
|
||||
})
|
||||
|
||||
result.current.handlePaneContextmenuCancel()
|
||||
|
||||
@@ -174,7 +174,7 @@ describe('useSelectionInteractions', () => {
|
||||
it('handleSelectionContextMenu should set menu only when clicking on selection rect', () => {
|
||||
const { result, store } = renderSelectionInteractions({
|
||||
nodeMenu: { clientX: 20, clientY: 10, nodeId: 'n1' },
|
||||
panelMenu: { top: 30, left: 40 },
|
||||
panelMenu: { clientX: 40, clientY: 30 },
|
||||
edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e1' },
|
||||
})
|
||||
|
||||
|
||||
@@ -22,15 +22,13 @@ export const usePanelInteractions = () => {
|
||||
workflowStore.getState().setClipboardData({ nodes, edges })
|
||||
})
|
||||
|
||||
const container = document.querySelector('#workflow-container')
|
||||
const { x, y } = container!.getBoundingClientRect()
|
||||
workflowStore.setState({
|
||||
nodeMenu: undefined,
|
||||
selectionMenu: undefined,
|
||||
edgeMenu: undefined,
|
||||
panelMenu: {
|
||||
top: e.clientY - y,
|
||||
left: e.clientX - x,
|
||||
clientX: e.clientX,
|
||||
clientY: e.clientY,
|
||||
},
|
||||
})
|
||||
}, [workflowStore, appDslVersion])
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useClickAway } from 'ahooks'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuGroup,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
} from '@langgenius/dify-ui/context-menu'
|
||||
import {
|
||||
memo,
|
||||
useRef,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Divider from '../base/divider'
|
||||
import {
|
||||
useDSL,
|
||||
useIsChatMode,
|
||||
useNodesInteractions,
|
||||
usePanelInteractions,
|
||||
useWorkflowMoveMode,
|
||||
@@ -20,7 +27,6 @@ import { useStore } from './store'
|
||||
|
||||
const PanelContextmenu = () => {
|
||||
const { t } = useTranslation()
|
||||
const ref = useRef(null)
|
||||
const panelMenu = useStore(s => s.panelMenu)
|
||||
const clipboardElements = useStore(s => s.clipboardElements)
|
||||
const setShowImportDSLModal = useStore(s => s.setShowImportDSLModal)
|
||||
@@ -29,127 +35,147 @@ const PanelContextmenu = () => {
|
||||
const setCommentQuickAdd = useStore(s => s.setCommentQuickAdd)
|
||||
const { handleNodesPaste } = useNodesInteractions()
|
||||
const { handlePaneContextmenuCancel } = usePanelInteractions()
|
||||
const { handleStartWorkflowRun } = useWorkflowStartRun()
|
||||
const {
|
||||
handleStartWorkflowRun,
|
||||
handleWorkflowStartRunInChatflow,
|
||||
} = useWorkflowStartRun()
|
||||
const { handleAddNote } = useOperator()
|
||||
const { isCommentModeAvailable } = useWorkflowMoveMode()
|
||||
const { exportCheck } = useDSL()
|
||||
const isChatMode = useIsChatMode()
|
||||
const panelMenuClientX = panelMenu?.clientX
|
||||
const panelMenuClientY = panelMenu?.clientY
|
||||
|
||||
useClickAway(() => {
|
||||
handlePaneContextmenuCancel()
|
||||
}, ref)
|
||||
const anchor = useMemo(() => {
|
||||
if (panelMenuClientX === undefined || panelMenuClientY === undefined)
|
||||
return null
|
||||
|
||||
const renderTrigger = () => {
|
||||
return {
|
||||
getBoundingClientRect: () => DOMRect.fromRect({
|
||||
width: 0,
|
||||
height: 0,
|
||||
x: panelMenuClientX,
|
||||
y: panelMenuClientY,
|
||||
}),
|
||||
}
|
||||
}, [panelMenuClientX, panelMenuClientY])
|
||||
|
||||
const renderAddBlockTrigger = useCallback(() => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-full cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
className={cn(
|
||||
'mx-1 flex h-8 w-[calc(100%-8px)] items-center rounded-lg outline-hidden hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover',
|
||||
'justify-between gap-4 px-3 text-text-secondary',
|
||||
)}
|
||||
>
|
||||
{t('common.addBlock', { ns: 'workflow' })}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
}, [t])
|
||||
|
||||
if (!panelMenu)
|
||||
const handleRunAction = useCallback(() => {
|
||||
if (isChatMode)
|
||||
handleWorkflowStartRunInChatflow()
|
||||
else
|
||||
handleStartWorkflowRun()
|
||||
|
||||
handlePaneContextmenuCancel()
|
||||
}, [isChatMode, handleWorkflowStartRunInChatflow, handleStartWorkflowRun, handlePaneContextmenuCancel])
|
||||
|
||||
if (!panelMenu || !anchor)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute z-9 w-[200px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg"
|
||||
style={{
|
||||
left: panelMenu.left,
|
||||
top: panelMenu.top,
|
||||
}}
|
||||
ref={ref}
|
||||
<ContextMenu
|
||||
open
|
||||
onOpenChange={open => !open && handlePaneContextmenuCancel()}
|
||||
>
|
||||
<div className="p-1">
|
||||
<AddBlock
|
||||
renderTrigger={renderTrigger}
|
||||
offset={{
|
||||
mainAxis: -36,
|
||||
crossAxis: -4,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-full cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleAddNote()
|
||||
handlePaneContextmenuCancel()
|
||||
}}
|
||||
>
|
||||
{t('nodes.note.addNote', { ns: 'workflow' })}
|
||||
</button>
|
||||
{isCommentModeAvailable && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!!pendingComment}
|
||||
className={cn(
|
||||
'flex h-8 w-full items-center justify-between rounded-lg px-3 text-sm text-text-secondary',
|
||||
pendingComment ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:bg-state-base-hover',
|
||||
)}
|
||||
<ContextMenuContent
|
||||
positionerProps={{ anchor }}
|
||||
popupClassName="w-[200px] rounded-lg"
|
||||
>
|
||||
<ContextMenuGroup>
|
||||
<AddBlock
|
||||
renderTrigger={renderAddBlockTrigger}
|
||||
offset={{
|
||||
mainAxis: -36,
|
||||
crossAxis: -4,
|
||||
}}
|
||||
/>
|
||||
<ContextMenuItem
|
||||
className="justify-between gap-4 px-3 text-text-secondary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (pendingComment)
|
||||
return
|
||||
setCommentQuickAdd(true)
|
||||
setCommentPlacing(true)
|
||||
handleAddNote()
|
||||
handlePaneContextmenuCancel()
|
||||
}}
|
||||
>
|
||||
{t('comments.actions.addComment', { ns: 'workflow' })}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-full cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
handleStartWorkflowRun()
|
||||
handlePaneContextmenuCancel()
|
||||
}}
|
||||
>
|
||||
{t('common.run', { ns: 'workflow' })}
|
||||
<ShortcutKbd shortcut="workflow.open-test-run-menu" />
|
||||
</button>
|
||||
</div>
|
||||
<Divider className="m-0" />
|
||||
<div className="p-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!clipboardElements.length}
|
||||
className={cn(
|
||||
'flex h-8 w-full cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary',
|
||||
!clipboardElements.length ? 'cursor-not-allowed opacity-50' : 'hover:bg-state-base-hover',
|
||||
{t('nodes.note.addNote', { ns: 'workflow' })}
|
||||
</ContextMenuItem>
|
||||
{isCommentModeAvailable && (
|
||||
<ContextMenuItem
|
||||
disabled={!!pendingComment}
|
||||
className={cn(
|
||||
'justify-between gap-4 px-3 text-text-secondary',
|
||||
pendingComment && 'cursor-not-allowed opacity-50',
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (pendingComment)
|
||||
return
|
||||
setCommentQuickAdd(true)
|
||||
setCommentPlacing(true)
|
||||
handlePaneContextmenuCancel()
|
||||
}}
|
||||
>
|
||||
{t('comments.actions.addComment', { ns: 'workflow' })}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
onClick={() => {
|
||||
if (clipboardElements.length) {
|
||||
handleNodesPaste()
|
||||
handlePaneContextmenuCancel()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('common.pasteHere', { ns: 'workflow' })}
|
||||
<ShortcutKbd shortcut="workflow.paste" />
|
||||
</button>
|
||||
</div>
|
||||
<Divider className="m-0" />
|
||||
<div className="p-1">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-full cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => exportCheck?.()}
|
||||
>
|
||||
{t('export', { ns: 'app' })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-full cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => setShowImportDSLModal(true)}
|
||||
>
|
||||
{t('importApp', { ns: 'app' })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ContextMenuItem
|
||||
className="justify-between gap-4 px-3 text-text-secondary"
|
||||
onClick={handleRunAction}
|
||||
>
|
||||
{isChatMode ? t('common.debugAndPreview', { ns: 'workflow' }) : t('common.run', { ns: 'workflow' })}
|
||||
{!isChatMode && <ShortcutKbd shortcut="workflow.open-test-run-menu" />}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuGroup>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuGroup>
|
||||
<ContextMenuItem
|
||||
disabled={!clipboardElements.length}
|
||||
className={cn(
|
||||
'justify-between gap-4 px-3 text-text-secondary',
|
||||
!clipboardElements.length && 'cursor-not-allowed opacity-50',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (clipboardElements.length) {
|
||||
handleNodesPaste()
|
||||
handlePaneContextmenuCancel()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('common.pasteHere', { ns: 'workflow' })}
|
||||
<ShortcutKbd shortcut="workflow.paste" />
|
||||
</ContextMenuItem>
|
||||
</ContextMenuGroup>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuGroup>
|
||||
<ContextMenuItem
|
||||
className="justify-between gap-4 px-3 text-text-secondary"
|
||||
onClick={() => exportCheck?.()}
|
||||
>
|
||||
{t('export', { ns: 'app' })}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className="justify-between gap-4 px-3 text-text-secondary"
|
||||
onClick={() => setShowImportDSLModal(true)}
|
||||
>
|
||||
{t('importApp', { ns: 'app' })}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuGroup>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ type MockRestoreConfirmModalProps = {
|
||||
type MockVersionHistoryItemProps = {
|
||||
item: VersionHistory
|
||||
onClick: (item: VersionHistory) => void
|
||||
handleClickMenuItem: (operation: VersionHistoryContextMenuOptions) => void
|
||||
handleClickActionMenuItem: (operation: VersionHistoryContextMenuOptions) => void
|
||||
}
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
@@ -148,7 +148,7 @@ vi.mock('@/app/components/app/app-publisher/version-info-modal', () => ({
|
||||
vi.mock('../version-history-item', () => ({
|
||||
default: (props: MockVersionHistoryItemProps) => {
|
||||
const MockVersionHistoryItem = () => {
|
||||
const { item, onClick, handleClickMenuItem } = props
|
||||
const { item, onClick, handleClickActionMenuItem } = props
|
||||
|
||||
useEffect(() => {
|
||||
if (item.version === WorkflowVersion.Draft)
|
||||
@@ -159,7 +159,7 @@ vi.mock('../version-history-item', () => ({
|
||||
<div>
|
||||
<button onClick={() => onClick(item)}>{item.marked_name || item.version}</button>
|
||||
{item.version !== WorkflowVersion.Draft && (
|
||||
<button onClick={() => handleClickMenuItem(VersionHistoryContextMenuOptions.restore)}>
|
||||
<button onClick={() => handleClickActionMenuItem(VersionHistoryContextMenuOptions.restore)}>
|
||||
{`restore-${item.id}`}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -60,7 +60,7 @@ describe('VersionHistoryItem', () => {
|
||||
currentVersion={null}
|
||||
latestVersionId="latest-version"
|
||||
onClick={onClick}
|
||||
handleClickMenuItem={vi.fn()}
|
||||
handleClickActionMenuItem={vi.fn()}
|
||||
isLast={false}
|
||||
/>,
|
||||
)
|
||||
@@ -81,7 +81,7 @@ describe('VersionHistoryItem', () => {
|
||||
describe('Published Items', () => {
|
||||
it('should open the context menu for a latest named version and forward restore', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleClickMenuItem = vi.fn()
|
||||
const handleClickActionMenuItem = vi.fn()
|
||||
const onClick = vi.fn()
|
||||
|
||||
render(
|
||||
@@ -90,7 +90,7 @@ describe('VersionHistoryItem', () => {
|
||||
currentVersion={null}
|
||||
latestVersionId="version-1"
|
||||
onClick={onClick}
|
||||
handleClickMenuItem={handleClickMenuItem}
|
||||
handleClickActionMenuItem={handleClickActionMenuItem}
|
||||
isLast={false}
|
||||
/>,
|
||||
)
|
||||
@@ -120,8 +120,8 @@ describe('VersionHistoryItem', () => {
|
||||
|
||||
fireEvent.click(restoreItem)
|
||||
|
||||
expect(handleClickMenuItem).toHaveBeenCalledTimes(1)
|
||||
expect(handleClickMenuItem).toHaveBeenCalledWith(
|
||||
expect(handleClickActionMenuItem).toHaveBeenCalledTimes(1)
|
||||
expect(handleClickActionMenuItem).toHaveBeenCalledWith(
|
||||
VersionHistoryContextMenuOptions.restore,
|
||||
VersionHistoryContextMenuOptions.restore,
|
||||
)
|
||||
@@ -138,7 +138,7 @@ describe('VersionHistoryItem', () => {
|
||||
currentVersion={item}
|
||||
latestVersionId="other-version"
|
||||
onClick={onClick}
|
||||
handleClickMenuItem={vi.fn()}
|
||||
handleClickActionMenuItem={vi.fn()}
|
||||
isLast
|
||||
/>,
|
||||
)
|
||||
|
||||
@@ -2,9 +2,9 @@ import { DropdownMenu, DropdownMenuContent } from '@langgenius/dify-ui/dropdown-
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { VersionHistoryContextMenuOptions } from '../../../../types'
|
||||
import MenuItem from '../menu-item'
|
||||
import ActionMenuItem from '../action-menu-item'
|
||||
|
||||
describe('MenuItem', () => {
|
||||
describe('ActionMenuItem', () => {
|
||||
it('forwards the selected operation and supports destructive styling', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClick = vi.fn()
|
||||
@@ -12,7 +12,7 @@ describe('MenuItem', () => {
|
||||
render(
|
||||
<DropdownMenu open onOpenChange={vi.fn()}>
|
||||
<DropdownMenuContent>
|
||||
<MenuItem
|
||||
<ActionMenuItem
|
||||
item={{
|
||||
key: VersionHistoryContextMenuOptions.delete,
|
||||
name: 'Delete',
|
||||
@@ -2,21 +2,21 @@ import { screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { renderWorkflowComponent } from '../../../../__tests__/workflow-test-env'
|
||||
import { VersionHistoryContextMenuOptions } from '../../../../types'
|
||||
import ContextMenu from '../index'
|
||||
import ActionMenu from '../index'
|
||||
|
||||
describe('ContextMenu', () => {
|
||||
describe('ActionMenu', () => {
|
||||
it('toggles the trigger and forwards menu clicks', async () => {
|
||||
const user = userEvent.setup()
|
||||
const setOpen = vi.fn()
|
||||
const handleClickMenuItem = vi.fn()
|
||||
const handleClickActionMenuItem = vi.fn()
|
||||
|
||||
renderWorkflowComponent(
|
||||
<ContextMenu
|
||||
<ActionMenu
|
||||
isNamedVersion
|
||||
isShowDelete
|
||||
open
|
||||
setOpen={setOpen}
|
||||
handleClickMenuItem={handleClickMenuItem}
|
||||
handleClickActionMenuItem={handleClickActionMenuItem}
|
||||
/>,
|
||||
)
|
||||
|
||||
@@ -25,11 +25,11 @@ describe('ContextMenu', () => {
|
||||
await user.click(screen.getByText('common.operation.delete'))
|
||||
|
||||
expect(setOpen).toHaveBeenCalled()
|
||||
expect(handleClickMenuItem).toHaveBeenCalledWith(
|
||||
expect(handleClickActionMenuItem).toHaveBeenCalledWith(
|
||||
VersionHistoryContextMenuOptions.restore,
|
||||
VersionHistoryContextMenuOptions.restore,
|
||||
)
|
||||
expect(handleClickMenuItem).toHaveBeenCalledWith(
|
||||
expect(handleClickActionMenuItem).toHaveBeenCalledWith(
|
||||
VersionHistoryContextMenuOptions.delete,
|
||||
VersionHistoryContextMenuOptions.delete,
|
||||
)
|
||||
@@ -1,15 +1,15 @@
|
||||
import { renderWorkflowHook } from '../../../../__tests__/workflow-test-env'
|
||||
import { VersionHistoryContextMenuOptions } from '../../../../types'
|
||||
import useContextMenu from '../use-context-menu'
|
||||
import useActionMenu from '../use-action-menu'
|
||||
|
||||
describe('useContextMenu', () => {
|
||||
describe('useActionMenu', () => {
|
||||
it('returns restore, edit, export, copy and delete operations for app workflows', () => {
|
||||
const { result } = renderWorkflowHook(() => useContextMenu({
|
||||
const { result } = renderWorkflowHook(() => useActionMenu({
|
||||
isNamedVersion: true,
|
||||
isShowDelete: false,
|
||||
open: false,
|
||||
setOpen: vi.fn(),
|
||||
handleClickMenuItem: vi.fn(),
|
||||
handleClickActionMenuItem: vi.fn(),
|
||||
}))
|
||||
|
||||
expect(result.current.deleteOperation).toEqual({
|
||||
@@ -25,12 +25,12 @@ describe('useContextMenu', () => {
|
||||
})
|
||||
|
||||
it('omits export for pipelines and renames the edit action for unnamed versions', () => {
|
||||
const { result } = renderWorkflowHook(() => useContextMenu({
|
||||
const { result } = renderWorkflowHook(() => useActionMenu({
|
||||
isNamedVersion: false,
|
||||
isShowDelete: true,
|
||||
open: false,
|
||||
setOpen: vi.fn(),
|
||||
handleClickMenuItem: vi.fn(),
|
||||
handleClickActionMenuItem: vi.fn(),
|
||||
}), {
|
||||
initialStoreState: {
|
||||
pipelineId: 'pipeline-1',
|
||||
@@ -4,7 +4,7 @@ import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { DropdownMenuItem } from '@langgenius/dify-ui/dropdown-menu'
|
||||
import * as React from 'react'
|
||||
|
||||
type MenuItemProps = {
|
||||
type ActionMenuItemProps = {
|
||||
item: {
|
||||
key: VersionHistoryContextMenuOptions
|
||||
name: string
|
||||
@@ -13,7 +13,7 @@ type MenuItemProps = {
|
||||
isDestructive?: boolean
|
||||
}
|
||||
|
||||
const MenuItem: FC<MenuItemProps> = ({
|
||||
const ActionMenuItem: FC<ActionMenuItemProps> = ({
|
||||
item,
|
||||
onClick,
|
||||
isDestructive = false,
|
||||
@@ -41,4 +41,4 @@ const MenuItem: FC<MenuItemProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(MenuItem)
|
||||
export default React.memo(ActionMenuItem)
|
||||
@@ -9,23 +9,23 @@ import {
|
||||
import { RiMoreFill } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { VersionHistoryContextMenuOptions } from '../../../types'
|
||||
import MenuItem from './menu-item'
|
||||
import useContextMenu from './use-context-menu'
|
||||
import ActionMenuItem from './action-menu-item'
|
||||
import useActionMenu from './use-action-menu'
|
||||
|
||||
export type ContextMenuProps = {
|
||||
export type ActionMenuProps = {
|
||||
isShowDelete: boolean
|
||||
isNamedVersion: boolean
|
||||
open: boolean
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>
|
||||
handleClickMenuItem: (operation: VersionHistoryContextMenuOptions) => void
|
||||
handleClickActionMenuItem: (operation: VersionHistoryContextMenuOptions) => void
|
||||
}
|
||||
|
||||
const ContextMenu: FC<ContextMenuProps> = (props: ContextMenuProps) => {
|
||||
const { isShowDelete, handleClickMenuItem, open, setOpen } = props
|
||||
const ActionMenu: FC<ActionMenuProps> = (props: ActionMenuProps) => {
|
||||
const { isShowDelete, handleClickActionMenuItem, open, setOpen } = props
|
||||
const {
|
||||
deleteOperation,
|
||||
options,
|
||||
} = useContextMenu(props)
|
||||
} = useActionMenu(props)
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
@@ -44,10 +44,10 @@ const ContextMenu: FC<ContextMenuProps> = (props: ContextMenuProps) => {
|
||||
>
|
||||
{
|
||||
options.map(option => (
|
||||
<MenuItem
|
||||
<ActionMenuItem
|
||||
key={option.key}
|
||||
item={option}
|
||||
onClick={handleClickMenuItem.bind(null, option.key)}
|
||||
onClick={handleClickActionMenuItem.bind(null, option.key)}
|
||||
/>
|
||||
))
|
||||
}
|
||||
@@ -55,10 +55,10 @@ const ContextMenu: FC<ContextMenuProps> = (props: ContextMenuProps) => {
|
||||
isShowDelete && (
|
||||
<>
|
||||
<DropdownMenuSeparator className="my-0" />
|
||||
<MenuItem
|
||||
<ActionMenuItem
|
||||
item={deleteOperation}
|
||||
isDestructive
|
||||
onClick={handleClickMenuItem.bind(null, VersionHistoryContextMenuOptions.delete)}
|
||||
onClick={handleClickActionMenuItem.bind(null, VersionHistoryContextMenuOptions.delete)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
@@ -68,4 +68,4 @@ const ContextMenu: FC<ContextMenuProps> = (props: ContextMenuProps) => {
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(ContextMenu)
|
||||
export default React.memo(ActionMenu)
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { ContextMenuProps } from './index'
|
||||
import type { ActionMenuProps } from './index'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import { VersionHistoryContextMenuOptions } from '../../../types'
|
||||
|
||||
const useContextMenu = (props: ContextMenuProps) => {
|
||||
const useActionMenu = (props: ActionMenuProps) => {
|
||||
const {
|
||||
isNamedVersion,
|
||||
} = props
|
||||
@@ -43,7 +43,7 @@ const useContextMenu = (props: ContextMenuProps) => {
|
||||
name: t('versionHistory.copyId', { ns: 'workflow' }),
|
||||
},
|
||||
]
|
||||
}, [isNamedVersion, t])
|
||||
}, [isNamedVersion, pipelineId, t])
|
||||
|
||||
return {
|
||||
deleteOperation,
|
||||
@@ -51,4 +51,4 @@ const useContextMenu = (props: ContextMenuProps) => {
|
||||
}
|
||||
}
|
||||
|
||||
export default useContextMenu
|
||||
export default useActionMenu
|
||||
@@ -107,7 +107,7 @@ export const VersionHistoryPanel = ({
|
||||
setIsOnlyShowNamedVersions(false)
|
||||
}, [])
|
||||
|
||||
const handleClickMenuItem = useCallback((item: VersionHistory, operation: VersionHistoryContextMenuOptions) => {
|
||||
const handleClickActionMenuItem = useCallback((item: VersionHistory, operation: VersionHistoryContextMenuOptions) => {
|
||||
setOperatedItem(item)
|
||||
switch (operation) {
|
||||
case VersionHistoryContextMenuOptions.restore:
|
||||
@@ -292,7 +292,7 @@ export const VersionHistoryPanel = ({
|
||||
currentVersion={currentVersion}
|
||||
latestVersionId={latestVersionId || ''}
|
||||
onClick={handleVersionClick}
|
||||
handleClickMenuItem={handleClickMenuItem.bind(null, item)}
|
||||
handleClickActionMenuItem={handleClickActionMenuItem.bind(null, item)}
|
||||
isLast={isLast}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -6,14 +6,14 @@ import * as React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { WorkflowVersion } from '../../types'
|
||||
import ContextMenu from './context-menu'
|
||||
import ActionMenu from './action-menu'
|
||||
|
||||
type VersionHistoryItemProps = {
|
||||
item: VersionHistory
|
||||
currentVersion: VersionHistory | null
|
||||
latestVersionId: string
|
||||
onClick: (item: VersionHistory) => void
|
||||
handleClickMenuItem: (operation: VersionHistoryContextMenuOptions) => void
|
||||
handleClickActionMenuItem: (operation: VersionHistoryContextMenuOptions) => void
|
||||
isLast: boolean
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ const VersionHistoryItem: React.FC<VersionHistoryItemProps> = ({
|
||||
currentVersion,
|
||||
latestVersionId,
|
||||
onClick,
|
||||
handleClickMenuItem,
|
||||
handleClickActionMenuItem,
|
||||
isLast,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
@@ -122,15 +122,15 @@ const VersionHistoryItem: React.FC<VersionHistoryItemProps> = ({
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{/* Context Menu */}
|
||||
{/* Action Menu */}
|
||||
{!isDraft && isHovering && (
|
||||
<div className="absolute top-1 right-1">
|
||||
<ContextMenu
|
||||
<ActionMenu
|
||||
isShowDelete={!isLatest}
|
||||
isNamedVersion={!!item.marked_name}
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
handleClickMenuItem={handleClickMenuItem}
|
||||
handleClickActionMenuItem={handleClickActionMenuItem}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -108,7 +108,7 @@ describe('createWorkflowStore', () => {
|
||||
['showWorkflowVersionHistoryPanel', 'setShowWorkflowVersionHistoryPanel', true],
|
||||
['showInputsPanel', 'setShowInputsPanel', true],
|
||||
['showDebugAndPreviewPanel', 'setShowDebugAndPreviewPanel', true],
|
||||
['panelMenu', 'setPanelMenu', { top: 10, left: 20 }],
|
||||
['panelMenu', 'setPanelMenu', { clientX: 20, clientY: 10 }],
|
||||
['selectionMenu', 'setSelectionMenu', { clientX: 50, clientY: 60 }],
|
||||
['edgeMenu', 'setEdgeMenu', { clientX: 320, clientY: 180, edgeId: 'e1' }],
|
||||
['showVariableInspectPanel', 'setShowVariableInspectPanel', true],
|
||||
|
||||
@@ -19,12 +19,12 @@ describe('createPanelSlice', () => {
|
||||
|
||||
store.getState().setShowFeaturesPanel(true)
|
||||
store.getState().setShowDebugAndPreviewPanel(true)
|
||||
store.getState().setPanelMenu({ top: 24, left: 48 })
|
||||
store.getState().setPanelMenu({ clientX: 48, clientY: 24 })
|
||||
store.getState().setEdgeMenu({ clientX: 80, clientY: 120, edgeId: 'edge-1' })
|
||||
|
||||
expect(store.getState().showFeaturesPanel).toBe(true)
|
||||
expect(store.getState().showDebugAndPreviewPanel).toBe(true)
|
||||
expect(store.getState().panelMenu).toEqual({ top: 24, left: 48 })
|
||||
expect(store.getState().panelMenu).toEqual({ clientX: 48, clientY: 24 })
|
||||
expect(store.getState().edgeMenu).toEqual({ clientX: 80, clientY: 120, edgeId: 'edge-1' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,8 +17,8 @@ export type PanelSliceShape = {
|
||||
showUserCursors: boolean
|
||||
setShowUserCursors: (showUserCursors: boolean) => void
|
||||
panelMenu?: {
|
||||
top: number
|
||||
left: number
|
||||
clientX: number
|
||||
clientY: number
|
||||
}
|
||||
setPanelMenu: (panelMenu: PanelSliceShape['panelMenu']) => void
|
||||
selectionMenu?: {
|
||||
|
||||
Reference in New Issue
Block a user