mirror of https://github.com/langgenius/dify.git
276 lines
11 KiB
TypeScript
276 lines
11 KiB
TypeScript
import type { RefObject } from 'react'
|
|
import type { NodeApi, TreeApi } from 'react-arborist'
|
|
import type { TreeNodeData } from '../../type'
|
|
import { fireEvent, render, screen } from '@testing-library/react'
|
|
import {
|
|
ContextMenu,
|
|
ContextMenuContent,
|
|
ContextMenuTrigger,
|
|
} from '@/app/components/base/ui/context-menu'
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuTrigger,
|
|
} from '@/app/components/base/ui/dropdown-menu'
|
|
import { NODE_MENU_TYPE } from '../../constants'
|
|
import NodeMenu from './node-menu'
|
|
|
|
type MockWorkflowState = {
|
|
selectedNodeIds: Set<string>
|
|
hasClipboard: () => boolean
|
|
}
|
|
|
|
type MockFileOperations = {
|
|
fileInputRef: RefObject<HTMLInputElement | null>
|
|
folderInputRef: RefObject<HTMLInputElement | null>
|
|
showDeleteConfirm: boolean
|
|
isLoading: boolean
|
|
isDeleting: boolean
|
|
handleDownload: () => void
|
|
handleNewFile: () => void
|
|
handleNewFolder: () => void
|
|
handleFileChange: () => void
|
|
handleFolderChange: () => void
|
|
handleRename: () => void
|
|
handleDeleteClick: () => void
|
|
handleDeleteConfirm: () => void
|
|
handleDeleteCancel: () => void
|
|
}
|
|
|
|
type RenderNodeMenuProps = {
|
|
type?: 'root' | 'folder' | 'file'
|
|
menuType?: 'dropdown' | 'context'
|
|
nodeId?: string
|
|
onClose?: () => void
|
|
onImportSkills?: () => void
|
|
treeRef?: RefObject<TreeApi<TreeNodeData> | null>
|
|
node?: NodeApi<TreeNodeData>
|
|
}
|
|
|
|
function createFileOperationsMock(): MockFileOperations {
|
|
return ({
|
|
fileInputRef: { current: null },
|
|
folderInputRef: { current: null },
|
|
showDeleteConfirm: false,
|
|
isLoading: false,
|
|
isDeleting: false,
|
|
handleDownload: vi.fn(),
|
|
handleNewFile: vi.fn(),
|
|
handleNewFolder: vi.fn(),
|
|
handleFileChange: vi.fn(),
|
|
handleFolderChange: vi.fn(),
|
|
handleRename: vi.fn(),
|
|
handleDeleteClick: vi.fn(),
|
|
handleDeleteConfirm: vi.fn(),
|
|
handleDeleteCancel: vi.fn(),
|
|
})
|
|
}
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
storeState: {
|
|
selectedNodeIds: new Set<string>(),
|
|
hasClipboard: () => false,
|
|
} as MockWorkflowState,
|
|
cutNodes: vi.fn(),
|
|
fileOperations: createFileOperationsMock(),
|
|
}))
|
|
|
|
vi.mock('@/app/components/workflow/store', () => ({
|
|
useStore: (selector: (state: MockWorkflowState) => unknown) => selector(mocks.storeState),
|
|
useWorkflowStore: () => ({
|
|
getState: () => ({
|
|
cutNodes: mocks.cutNodes,
|
|
}),
|
|
}),
|
|
}))
|
|
|
|
vi.mock('../../hooks/file-tree/operations/use-file-operations', () => ({
|
|
useFileOperations: () => mocks.fileOperations,
|
|
}))
|
|
|
|
const renderNodeMenu = ({
|
|
type = NODE_MENU_TYPE.FOLDER,
|
|
menuType = 'dropdown',
|
|
nodeId = 'node-1',
|
|
onClose = vi.fn(),
|
|
onImportSkills,
|
|
treeRef,
|
|
node,
|
|
}: RenderNodeMenuProps = {}) => {
|
|
const ui = menuType === 'dropdown'
|
|
? (
|
|
<DropdownMenu open>
|
|
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
|
|
<DropdownMenuContent>
|
|
<NodeMenu
|
|
type={type}
|
|
menuType={menuType}
|
|
nodeId={nodeId}
|
|
onClose={onClose}
|
|
onImportSkills={onImportSkills}
|
|
treeRef={treeRef}
|
|
node={node}
|
|
/>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)
|
|
: (
|
|
<ContextMenu open>
|
|
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
|
|
<ContextMenuContent>
|
|
<NodeMenu
|
|
type={type}
|
|
menuType={menuType}
|
|
nodeId={nodeId}
|
|
onClose={onClose}
|
|
onImportSkills={onImportSkills}
|
|
treeRef={treeRef}
|
|
node={node}
|
|
/>
|
|
</ContextMenuContent>
|
|
</ContextMenu>
|
|
)
|
|
|
|
render(
|
|
ui,
|
|
)
|
|
|
|
return { onClose }
|
|
}
|
|
|
|
describe('NodeMenu', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mocks.storeState.selectedNodeIds = new Set<string>()
|
|
mocks.storeState.hasClipboard = () => false
|
|
mocks.fileOperations = createFileOperationsMock()
|
|
})
|
|
|
|
describe('Rendering', () => {
|
|
it('should render root folder actions and hide file-only actions', () => {
|
|
renderNodeMenu({ type: NODE_MENU_TYPE.ROOT })
|
|
|
|
expect(screen.getByRole('menuitem', { name: /workflow\.skillSidebar\.menu\.newFile/i })).toBeInTheDocument()
|
|
expect(screen.getByRole('menuitem', { name: /workflow\.skillSidebar\.menu\.newFolder/i })).toBeInTheDocument()
|
|
expect(screen.getByRole('menuitem', { name: /workflow\.skillSidebar\.menu\.uploadFile/i })).toBeInTheDocument()
|
|
expect(screen.getByRole('menuitem', { name: /workflow\.skillSidebar\.menu\.uploadFolder/i })).toBeInTheDocument()
|
|
expect(screen.getByRole('menuitem', { name: /workflow\.skillSidebar\.menu\.importSkills/i })).toBeInTheDocument()
|
|
expect(screen.queryByRole('menuitem', { name: /workflow\.skillSidebar\.menu\.cut/i })).not.toBeInTheDocument()
|
|
expect(screen.queryByRole('menuitem', { name: /workflow\.skillSidebar\.menu\.rename/i })).not.toBeInTheDocument()
|
|
expect(screen.queryByRole('menuitem', { name: /workflow\.skillSidebar\.menu\.delete/i })).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('should render file actions and hide folder-only actions', () => {
|
|
renderNodeMenu({ type: NODE_MENU_TYPE.FILE })
|
|
|
|
expect(screen.getByRole('menuitem', { name: /workflow\.skillSidebar\.menu\.download/i })).toBeInTheDocument()
|
|
expect(screen.getByRole('menuitem', { name: /workflow\.skillSidebar\.menu\.cut/i })).toBeInTheDocument()
|
|
expect(screen.getByRole('menuitem', { name: /workflow\.skillSidebar\.menu\.rename/i })).toBeInTheDocument()
|
|
expect(screen.getByRole('menuitem', { name: /workflow\.skillSidebar\.menu\.delete/i })).toBeInTheDocument()
|
|
expect(screen.queryByRole('menuitem', { name: /workflow\.skillSidebar\.menu\.newFile/i })).not.toBeInTheDocument()
|
|
expect(screen.queryByRole('menuitem', { name: /workflow\.skillSidebar\.menu\.newFolder/i })).not.toBeInTheDocument()
|
|
expect(screen.queryByRole('menuitem', { name: /workflow\.skillSidebar\.menu\.paste/i })).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('should disable menu actions when file operations are loading', () => {
|
|
mocks.fileOperations.isLoading = true
|
|
renderNodeMenu({ type: NODE_MENU_TYPE.FOLDER })
|
|
|
|
expect(screen.getByRole('menuitem', { name: /workflow\.skillSidebar\.menu\.newFile/i })).toHaveAttribute('aria-disabled', 'true')
|
|
expect(screen.getByRole('menuitem', { name: /workflow\.skillSidebar\.menu\.newFolder/i })).toHaveAttribute('aria-disabled', 'true')
|
|
expect(screen.getByRole('menuitem', { name: /workflow\.skillSidebar\.menu\.uploadFile/i })).toHaveAttribute('aria-disabled', 'true')
|
|
})
|
|
})
|
|
|
|
describe('Menu actions', () => {
|
|
it('should trigger create operations when clicking new file and new folder', () => {
|
|
renderNodeMenu({ type: NODE_MENU_TYPE.FOLDER })
|
|
|
|
fireEvent.click(screen.getByRole('menuitem', { name: /workflow\.skillSidebar\.menu\.newFile/i }))
|
|
fireEvent.click(screen.getByRole('menuitem', { name: /workflow\.skillSidebar\.menu\.newFolder/i }))
|
|
|
|
expect(mocks.fileOperations.handleNewFile).toHaveBeenCalledTimes(1)
|
|
expect(mocks.fileOperations.handleNewFolder).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('should trigger hidden file and folder input clicks from upload actions', () => {
|
|
const clickSpy = vi.spyOn(HTMLInputElement.prototype, 'click')
|
|
renderNodeMenu({ type: NODE_MENU_TYPE.FOLDER })
|
|
|
|
fireEvent.click(screen.getByRole('menuitem', { name: /workflow\.skillSidebar\.menu\.uploadFile/i }))
|
|
fireEvent.click(screen.getByRole('menuitem', { name: /workflow\.skillSidebar\.menu\.uploadFolder/i }))
|
|
|
|
expect(clickSpy).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('should cut selected nodes and close menu when cut is clicked', () => {
|
|
mocks.storeState.selectedNodeIds = new Set(['file-1', 'file-2'])
|
|
const { onClose } = renderNodeMenu({ type: NODE_MENU_TYPE.FILE, nodeId: 'fallback-id' })
|
|
|
|
fireEvent.click(screen.getByRole('menuitem', { name: /workflow\.skillSidebar\.menu\.cut/i }))
|
|
|
|
expect(mocks.cutNodes).toHaveBeenCalledTimes(1)
|
|
expect(mocks.cutNodes).toHaveBeenCalledWith(['file-1', 'file-2'])
|
|
expect(onClose).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('should cut current node id when no multi-selection exists', () => {
|
|
const { onClose } = renderNodeMenu({ type: NODE_MENU_TYPE.FILE, nodeId: 'file-3' })
|
|
|
|
fireEvent.click(screen.getByRole('menuitem', { name: /workflow\.skillSidebar\.menu\.cut/i }))
|
|
|
|
expect(mocks.cutNodes).toHaveBeenCalledWith(['file-3'])
|
|
expect(onClose).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('should dispatch paste event and close when paste is clicked', () => {
|
|
mocks.storeState.hasClipboard = () => true
|
|
const pasteListener = vi.fn()
|
|
window.addEventListener('skill:paste', pasteListener)
|
|
const { onClose } = renderNodeMenu({ type: NODE_MENU_TYPE.FOLDER })
|
|
|
|
fireEvent.click(screen.getByRole('menuitem', { name: /workflow\.skillSidebar\.menu\.paste/i }))
|
|
|
|
expect(pasteListener).toHaveBeenCalledTimes(1)
|
|
expect(onClose).toHaveBeenCalledTimes(1)
|
|
window.removeEventListener('skill:paste', pasteListener)
|
|
})
|
|
|
|
it('should call download, rename, and delete handlers for file menu actions', () => {
|
|
renderNodeMenu({ type: NODE_MENU_TYPE.FILE })
|
|
|
|
fireEvent.click(screen.getByRole('menuitem', { name: /workflow\.skillSidebar\.menu\.download/i }))
|
|
fireEvent.click(screen.getByRole('menuitem', { name: /workflow\.skillSidebar\.menu\.rename/i }))
|
|
fireEvent.click(screen.getByRole('menuitem', { name: /workflow\.skillSidebar\.menu\.delete/i }))
|
|
|
|
expect(mocks.fileOperations.handleDownload).toHaveBeenCalledTimes(1)
|
|
expect(mocks.fileOperations.handleRename).toHaveBeenCalledTimes(1)
|
|
expect(mocks.fileOperations.handleDeleteClick).toHaveBeenCalledTimes(1)
|
|
})
|
|
})
|
|
|
|
describe('Dialogs', () => {
|
|
it('should call import handler from root menu', () => {
|
|
const onImportSkills = vi.fn()
|
|
renderNodeMenu({ type: NODE_MENU_TYPE.ROOT, onImportSkills })
|
|
|
|
fireEvent.click(screen.getByRole('menuitem', { name: /workflow\.skillSidebar\.menu\.importSkills/i }))
|
|
expect(onImportSkills).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('should render delete confirmation content for files and forward confirm callbacks', () => {
|
|
mocks.fileOperations.showDeleteConfirm = true
|
|
renderNodeMenu({ type: NODE_MENU_TYPE.FILE })
|
|
|
|
expect(screen.getByText('workflow.skillSidebar.menu.fileDeleteConfirmTitle')).toBeInTheDocument()
|
|
expect(screen.getByText('workflow.skillSidebar.menu.fileDeleteConfirmContent')).toBeInTheDocument()
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.confirm/i }))
|
|
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.cancel/i }))
|
|
|
|
expect(mocks.fileOperations.handleDeleteConfirm).toHaveBeenCalledTimes(1)
|
|
expect(mocks.fileOperations.handleDeleteCancel).toHaveBeenCalledTimes(1)
|
|
})
|
|
})
|
|
})
|