refactor(web): migrate workflow panel context menu primitive (#35787)

This commit is contained in:
yyh
2026-05-05 07:12:26 +08:00
committed by GitHub
parent b43ebf539d
commit 90fe54ca9e
18 changed files with 279 additions and 276 deletions

View File

@@ -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()
})
})
})

View File

@@ -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()

View File

@@ -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' },
})

View File

@@ -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])

View File

@@ -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>
)
}

View File

@@ -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>
)}

View File

@@ -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
/>,
)

View File

@@ -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',

View File

@@ -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,
)

View File

@@ -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',

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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}
/>
)

View File

@@ -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>
)}

View File

@@ -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],

View File

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

View File

@@ -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?: {