mirror of
https://github.com/langgenius/dify.git
synced 2026-04-05 20:22:39 +08:00
fix(skill): stabilize file tree local operations
This commit is contained in:
@@ -14,7 +14,6 @@ import { NODE_MENU_TYPE } from '../../constants'
|
||||
import NodeMenu from './node-menu'
|
||||
|
||||
type MockWorkflowState = {
|
||||
selectedNodeIds: Set<string>
|
||||
hasClipboard: () => boolean
|
||||
}
|
||||
|
||||
@@ -39,6 +38,7 @@ type RenderNodeMenuProps = {
|
||||
type?: 'root' | 'folder' | 'file'
|
||||
menuType?: 'dropdown' | 'context'
|
||||
nodeId?: string
|
||||
actionNodeIds?: string[]
|
||||
onClose?: () => void
|
||||
onImportSkills?: () => void
|
||||
}
|
||||
@@ -64,10 +64,10 @@ function createFileOperationsMock(): MockFileOperations {
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
storeState: {
|
||||
selectedNodeIds: new Set<string>(),
|
||||
hasClipboard: () => false,
|
||||
} as MockWorkflowState,
|
||||
cutNodes: vi.fn(),
|
||||
setFileTreeSearchTerm: vi.fn(),
|
||||
fileOperations: createFileOperationsMock(),
|
||||
}))
|
||||
|
||||
@@ -76,6 +76,7 @@ vi.mock('@/app/components/workflow/store', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
getState: () => ({
|
||||
cutNodes: mocks.cutNodes,
|
||||
setFileTreeSearchTerm: mocks.setFileTreeSearchTerm,
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
@@ -84,6 +85,7 @@ const renderNodeMenu = ({
|
||||
type = NODE_MENU_TYPE.FOLDER,
|
||||
menuType = 'dropdown',
|
||||
nodeId = 'node-1',
|
||||
actionNodeIds,
|
||||
onClose = vi.fn(),
|
||||
onImportSkills,
|
||||
}: RenderNodeMenuProps = {}) => {
|
||||
@@ -96,6 +98,7 @@ const renderNodeMenu = ({
|
||||
type={type}
|
||||
menuType={menuType}
|
||||
nodeId={nodeId}
|
||||
actionNodeIds={actionNodeIds}
|
||||
onClose={onClose}
|
||||
fileInputRef={mocks.fileOperations.fileInputRef}
|
||||
folderInputRef={mocks.fileOperations.folderInputRef}
|
||||
@@ -120,6 +123,7 @@ const renderNodeMenu = ({
|
||||
type={type}
|
||||
menuType={menuType}
|
||||
nodeId={nodeId}
|
||||
actionNodeIds={actionNodeIds}
|
||||
onClose={onClose}
|
||||
fileInputRef={mocks.fileOperations.fileInputRef}
|
||||
folderInputRef={mocks.fileOperations.folderInputRef}
|
||||
@@ -147,7 +151,6 @@ const renderNodeMenu = ({
|
||||
describe('NodeMenu', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.storeState.selectedNodeIds = new Set<string>()
|
||||
mocks.storeState.hasClipboard = () => false
|
||||
mocks.fileOperations = createFileOperationsMock()
|
||||
})
|
||||
@@ -195,6 +198,8 @@ describe('NodeMenu', () => {
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /workflow\.skillSidebar\.menu\.newFile/i }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /workflow\.skillSidebar\.menu\.newFolder/i }))
|
||||
|
||||
expect(mocks.setFileTreeSearchTerm).toHaveBeenNthCalledWith(1, '')
|
||||
expect(mocks.setFileTreeSearchTerm).toHaveBeenNthCalledWith(2, '')
|
||||
expect(mocks.fileOperations.handleNewFile).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.fileOperations.handleNewFolder).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
@@ -209,9 +214,12 @@ describe('NodeMenu', () => {
|
||||
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' })
|
||||
it('should cut explicit action node ids and close menu when cut is clicked', () => {
|
||||
const { onClose } = renderNodeMenu({
|
||||
type: NODE_MENU_TYPE.FILE,
|
||||
nodeId: 'fallback-id',
|
||||
actionNodeIds: ['file-1', 'file-2'],
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /workflow\.skillSidebar\.menu\.cut/i }))
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ type NodeMenuProps = {
|
||||
type: NodeMenuType
|
||||
menuType: 'dropdown' | 'context'
|
||||
nodeId?: string
|
||||
actionNodeIds?: string[]
|
||||
onClose: () => void
|
||||
fileInputRef: React.RefObject<HTMLInputElement | null>
|
||||
folderInputRef: React.RefObject<HTMLInputElement | null>
|
||||
@@ -42,6 +43,7 @@ const NodeMenu = ({
|
||||
type,
|
||||
menuType,
|
||||
nodeId,
|
||||
actionNodeIds,
|
||||
onClose,
|
||||
fileInputRef,
|
||||
folderInputRef,
|
||||
@@ -57,7 +59,6 @@ const NodeMenu = ({
|
||||
}: NodeMenuProps) => {
|
||||
const { t } = useTranslation('workflow')
|
||||
const storeApi = useWorkflowStore()
|
||||
const selectedNodeIds = useStore(s => s.selectedNodeIds)
|
||||
const hasClipboard = useStore(s => s.hasClipboard())
|
||||
const isRoot = type === NODE_MENU_TYPE.ROOT
|
||||
const isFolder = type === NODE_MENU_TYPE.FOLDER || isRoot
|
||||
@@ -65,18 +66,30 @@ const NodeMenu = ({
|
||||
const currentNodeId = nodeId
|
||||
|
||||
const handleCut = useCallback(() => {
|
||||
const ids = selectedNodeIds.size > 0 ? [...selectedNodeIds] : (currentNodeId ? [currentNodeId] : [])
|
||||
const ids = actionNodeIds && actionNodeIds.length > 0
|
||||
? actionNodeIds
|
||||
: (currentNodeId ? [currentNodeId] : [])
|
||||
if (ids.length > 0) {
|
||||
storeApi.getState().cutNodes(ids)
|
||||
onClose()
|
||||
}
|
||||
}, [currentNodeId, onClose, selectedNodeIds, storeApi])
|
||||
}, [actionNodeIds, currentNodeId, onClose, storeApi])
|
||||
|
||||
const handlePaste = useCallback(() => {
|
||||
window.dispatchEvent(new CustomEvent('skill:paste'))
|
||||
onClose()
|
||||
}, [onClose])
|
||||
|
||||
const handleCreateFile = useCallback(() => {
|
||||
storeApi.getState().setFileTreeSearchTerm('')
|
||||
onNewFile()
|
||||
}, [onNewFile, storeApi])
|
||||
|
||||
const handleCreateFolder = useCallback(() => {
|
||||
storeApi.getState().setFileTreeSearchTerm('')
|
||||
onNewFolder()
|
||||
}, [onNewFolder, storeApi])
|
||||
|
||||
const showRenameDelete = isFolder ? !isRoot : true
|
||||
const Separator = menuType === 'dropdown' ? DropdownMenuSeparator : ContextMenuSeparator
|
||||
|
||||
@@ -106,14 +119,14 @@ const NodeMenu = ({
|
||||
menuType={menuType}
|
||||
icon={FileAdd}
|
||||
label={t('skillSidebar.menu.newFile')}
|
||||
onClick={onNewFile}
|
||||
onClick={handleCreateFile}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<MenuItem
|
||||
menuType={menuType}
|
||||
icon={FolderAdd}
|
||||
label={t('skillSidebar.menu.newFolder')}
|
||||
onClick={onNewFolder}
|
||||
onClick={handleCreateFolder}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ROOT_ID } from '../../constants'
|
||||
import TreeContextMenu from './tree-context-menu'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
selectedNodeIds: new Set<string>(),
|
||||
clearSelection: vi.fn(),
|
||||
setSelectedNodeIds: vi.fn(),
|
||||
deselectAll: vi.fn(),
|
||||
@@ -30,6 +31,7 @@ const mocks = vi.hoisted(() => ({
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
getState: () => ({
|
||||
selectedNodeIds: mocks.selectedNodeIds,
|
||||
clearSelection: mocks.clearSelection,
|
||||
setSelectedNodeIds: mocks.setSelectedNodeIds,
|
||||
}),
|
||||
@@ -63,11 +65,12 @@ vi.mock('../../hooks/file-tree/operations/use-file-operations', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('./node-menu', () => ({
|
||||
default: ({ type, menuType, nodeId, onImportSkills }: { type: string, menuType: string, nodeId?: string, onImportSkills?: () => void }) => (
|
||||
default: ({ type, menuType, nodeId, actionNodeIds, onImportSkills }: { type: string, menuType: string, nodeId?: string, actionNodeIds?: string[], onImportSkills?: () => void }) => (
|
||||
<div
|
||||
data-testid={`node-menu-${menuType}`}
|
||||
data-type={type}
|
||||
data-node-id={nodeId ?? ''}
|
||||
data-action-node-ids={(actionNodeIds ?? []).join(',')}
|
||||
>
|
||||
{onImportSkills && (
|
||||
<button type="button" onClick={onImportSkills}>
|
||||
@@ -81,6 +84,7 @@ vi.mock('./node-menu', () => ({
|
||||
describe('TreeContextMenu', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.selectedNodeIds = new Set<string>()
|
||||
mocks.fileOperations.showDeleteConfirm = false
|
||||
})
|
||||
|
||||
@@ -143,6 +147,7 @@ describe('TreeContextMenu', () => {
|
||||
expect(mocks.clearSelection).not.toHaveBeenCalled()
|
||||
expect(screen.getByTestId('node-menu-context')).toHaveAttribute('data-type', 'file')
|
||||
expect(screen.getByTestId('node-menu-context')).toHaveAttribute('data-node-id', 'file-1')
|
||||
expect(screen.getByTestId('node-menu-context')).toHaveAttribute('data-action-node-ids', 'file-1')
|
||||
expect(mocks.useFileOperations).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
nodeId: 'file-1',
|
||||
nodeType: 'file',
|
||||
@@ -150,6 +155,31 @@ describe('TreeContextMenu', () => {
|
||||
}))
|
||||
})
|
||||
|
||||
it('should preserve multi-selection when right-click target is already selected', () => {
|
||||
mocks.selectedNodeIds = new Set(['file-1', 'file-2'])
|
||||
mocks.getNode.mockReturnValue({
|
||||
select: mocks.selectNode,
|
||||
data: { name: 'readme.md' },
|
||||
})
|
||||
|
||||
render(
|
||||
<TreeContextMenu treeRef={{ current: { deselectAll: mocks.deselectAll, get: mocks.getNode } as never }}>
|
||||
<div>
|
||||
<div data-skill-tree-node-id="file-1" data-skill-tree-node-type="file" role="treeitem">
|
||||
readme.md
|
||||
</div>
|
||||
</div>
|
||||
</TreeContextMenu>,
|
||||
)
|
||||
|
||||
fireEvent.contextMenu(screen.getByRole('treeitem'))
|
||||
|
||||
expect(mocks.deselectAll).not.toHaveBeenCalled()
|
||||
expect(mocks.selectNode).not.toHaveBeenCalled()
|
||||
expect(mocks.setSelectedNodeIds).not.toHaveBeenCalled()
|
||||
expect(screen.getByTestId('node-menu-context')).toHaveAttribute('data-action-node-ids', 'file-1,file-2')
|
||||
})
|
||||
|
||||
it('should keep import modal mounted after root menu requests it', () => {
|
||||
render(
|
||||
<TreeContextMenu treeRef={{ current: { deselectAll: mocks.deselectAll } as never }}>
|
||||
|
||||
@@ -33,11 +33,13 @@ type MenuTarget = {
|
||||
nodeId: string
|
||||
type: typeof NODE_MENU_TYPE.ROOT | typeof NODE_MENU_TYPE.FOLDER | typeof NODE_MENU_TYPE.FILE
|
||||
fileName?: string
|
||||
actionNodeIds: string[]
|
||||
}
|
||||
|
||||
const defaultMenuTarget: MenuTarget = {
|
||||
nodeId: ROOT_ID,
|
||||
type: NODE_MENU_TYPE.ROOT,
|
||||
actionNodeIds: [],
|
||||
}
|
||||
|
||||
const TreeContextMenu = ({
|
||||
@@ -68,13 +70,23 @@ const TreeContextMenu = ({
|
||||
return
|
||||
|
||||
const targetNode = treeRef.current?.get(nodeId)
|
||||
treeRef.current?.deselectAll?.()
|
||||
targetNode?.select()
|
||||
storeApi.getState().setSelectedNodeIds([nodeId])
|
||||
const selectedNodeIds = storeApi.getState().selectedNodeIds
|
||||
const targetIsInSelection = selectedNodeIds.has(nodeId)
|
||||
const actionNodeIds = targetIsInSelection && selectedNodeIds.size > 0
|
||||
? [...selectedNodeIds]
|
||||
: [nodeId]
|
||||
|
||||
if (!targetIsInSelection) {
|
||||
treeRef.current?.deselectAll?.()
|
||||
targetNode?.select()
|
||||
storeApi.getState().setSelectedNodeIds([nodeId])
|
||||
}
|
||||
|
||||
setMenuTarget({
|
||||
nodeId,
|
||||
type: nodeType,
|
||||
fileName: targetNode?.data.name,
|
||||
actionNodeIds,
|
||||
})
|
||||
}, [storeApi, treeRef])
|
||||
|
||||
@@ -107,6 +119,7 @@ const TreeContextMenu = ({
|
||||
menuType="context"
|
||||
type={menuTarget.type}
|
||||
nodeId={menuTarget.nodeId}
|
||||
actionNodeIds={menuTarget.actionNodeIds}
|
||||
onClose={handleMenuClose}
|
||||
fileInputRef={fileOperations.fileInputRef}
|
||||
folderInputRef={fileOperations.folderInputRef}
|
||||
|
||||
@@ -6,6 +6,7 @@ import TreeNode from './tree-node'
|
||||
type MockWorkflowSelectorState = {
|
||||
dirtyContents: Set<string>
|
||||
isCutNode: (nodeId: string) => boolean
|
||||
selectedNodeIds: Set<string>
|
||||
}
|
||||
|
||||
type NodeState = {
|
||||
@@ -24,6 +25,7 @@ type NodeState = {
|
||||
const workflowState = vi.hoisted(() => ({
|
||||
dirtyContents: new Set<string>(),
|
||||
cutNodeIds: new Set<string>(),
|
||||
selectedNodeIds: new Set<string>(),
|
||||
dragOverFolderId: null as string | null,
|
||||
}))
|
||||
|
||||
@@ -69,6 +71,7 @@ vi.mock('@/app/components/workflow/store', () => ({
|
||||
useStore: (selector: (state: MockWorkflowSelectorState) => unknown) => selector({
|
||||
dirtyContents: workflowState.dirtyContents,
|
||||
isCutNode: (nodeId: string) => workflowState.cutNodeIds.has(nodeId),
|
||||
selectedNodeIds: workflowState.selectedNodeIds,
|
||||
}),
|
||||
useWorkflowStore: () => ({
|
||||
getState: () => ({
|
||||
@@ -114,15 +117,17 @@ vi.mock('./node-menu', () => ({
|
||||
default: ({
|
||||
type,
|
||||
menuType,
|
||||
actionNodeIds,
|
||||
onClose,
|
||||
onDeleteClick,
|
||||
}: {
|
||||
type: string
|
||||
menuType: string
|
||||
actionNodeIds?: string[]
|
||||
onClose: () => void
|
||||
onDeleteClick?: () => void
|
||||
}) => (
|
||||
<div data-testid={`node-menu-${menuType}`} data-type={type}>
|
||||
<div data-testid={`node-menu-${menuType}`} data-type={type} data-action-node-ids={(actionNodeIds ?? []).join(',')}>
|
||||
<button type="button" onClick={onClose}>close-menu</button>
|
||||
<button type="button" onClick={onDeleteClick}>delete-item</button>
|
||||
</div>
|
||||
@@ -179,6 +184,7 @@ describe('TreeNode', () => {
|
||||
|
||||
workflowState.dirtyContents.clear()
|
||||
workflowState.cutNodeIds.clear()
|
||||
workflowState.selectedNodeIds = new Set<string>()
|
||||
workflowState.dragOverFolderId = null
|
||||
|
||||
dndMocks.isDragOver = false
|
||||
@@ -202,6 +208,7 @@ describe('TreeNode', () => {
|
||||
})
|
||||
|
||||
it('should render selected open folder with folder expansion aria state', () => {
|
||||
workflowState.selectedNodeIds = new Set(['folder-1', 'folder-2'])
|
||||
const props = buildProps({
|
||||
id: 'folder-1',
|
||||
name: 'src',
|
||||
@@ -216,6 +223,8 @@ describe('TreeNode', () => {
|
||||
expect(treeItem).toHaveAttribute('aria-selected', 'true')
|
||||
expect(treeItem).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(treeItem).toHaveClass('bg-state-base-active')
|
||||
fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.moreActions/i }))
|
||||
expect(screen.getByTestId('node-menu-dropdown')).toHaveAttribute('data-action-node-ids', 'folder-1,folder-2')
|
||||
})
|
||||
|
||||
it('should apply drag-over, blinking, and cut styles when states are active', () => {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { NodeRendererProps } from 'react-arborist'
|
||||
import type { TreeNodeData } from '../../type'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -32,7 +32,13 @@ const TreeNode = ({ node, style, dragHandle, treeChildren }: TreeNodeProps) => {
|
||||
const isSelected = node.isSelected
|
||||
const isDirty = useStore(s => s.dirtyContents.has(node.data.id))
|
||||
const isCut = useStore(s => s.isCutNode(node.data.id))
|
||||
const selectedNodeIds = useStore(s => s.selectedNodeIds)
|
||||
const storeApi = useWorkflowStore()
|
||||
const actionNodeIds = useMemo(() => {
|
||||
if (node.isSelected && selectedNodeIds.size > 0)
|
||||
return [...selectedNodeIds]
|
||||
return [node.data.id]
|
||||
}, [node.data.id, node.isSelected, selectedNodeIds])
|
||||
|
||||
// Sync react-arborist drag state to Zustand for DragActionTooltip
|
||||
const prevIsDraggingRef = useRef(node.isDragging)
|
||||
@@ -181,6 +187,7 @@ const TreeNode = ({ node, style, dragHandle, treeChildren }: TreeNodeProps) => {
|
||||
menuType="dropdown"
|
||||
type={isFolder ? 'folder' : 'file'}
|
||||
nodeId={node.data.id}
|
||||
actionNodeIds={actionNodeIds}
|
||||
onClose={handleMenuClose}
|
||||
fileInputRef={fileOperations.fileInputRef}
|
||||
folderInputRef={fileOperations.folderInputRef}
|
||||
|
||||
@@ -19,6 +19,7 @@ const mocks = vi.hoisted(() => ({
|
||||
toastSuccess: vi.fn<(message: string) => void>(),
|
||||
toastError: vi.fn<(message: string) => void>(),
|
||||
getAllDescendantFileIds: vi.fn<(nodeId: string, nodes: TreeNodeData[]) => string[]>(),
|
||||
isDescendantOf: vi.fn<(potentialDescendantId: string | null | undefined, ancestorId: string | null | undefined, nodes: TreeNodeData[]) => boolean>(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-app-asset', () => ({
|
||||
@@ -34,6 +35,7 @@ vi.mock('../data/use-skill-tree-collaboration', () => ({
|
||||
|
||||
vi.mock('../../../utils/tree-utils', () => ({
|
||||
getAllDescendantFileIds: mocks.getAllDescendantFileIds,
|
||||
isDescendantOf: mocks.isDescendantOf,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/toast', () => ({
|
||||
@@ -75,19 +77,27 @@ const createTreeRef = (targetNode: NodeApi<TreeNodeData> | null) => {
|
||||
const createStoreApi = () => {
|
||||
const closeTab = vi.fn<(fileId: string) => void>()
|
||||
const clearDraftContent = vi.fn<(fileId: string) => void>()
|
||||
const clearFileMetadata = vi.fn<(fileId: string) => void>()
|
||||
const clearClipboard = vi.fn<() => void>()
|
||||
const state = {
|
||||
clipboard: null,
|
||||
closeTab,
|
||||
clearDraftContent,
|
||||
} as Pick<SkillEditorSliceShape, 'closeTab' | 'clearDraftContent'>
|
||||
clearFileMetadata,
|
||||
clearClipboard,
|
||||
} as Pick<SkillEditorSliceShape, 'clipboard' | 'closeTab' | 'clearDraftContent' | 'clearFileMetadata' | 'clearClipboard'>
|
||||
|
||||
const storeApi = {
|
||||
getState: () => state,
|
||||
} as unknown as StoreApi<SkillEditorSliceShape>
|
||||
|
||||
return {
|
||||
state,
|
||||
storeApi,
|
||||
closeTab,
|
||||
clearDraftContent,
|
||||
clearFileMetadata,
|
||||
clearClipboard,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +107,7 @@ describe('useModifyOperations', () => {
|
||||
mocks.deletePending = false
|
||||
mocks.deleteMutateAsync.mockResolvedValue(undefined)
|
||||
mocks.getAllDescendantFileIds.mockReturnValue([])
|
||||
mocks.isDescendantOf.mockReturnValue(false)
|
||||
})
|
||||
|
||||
// Scenario: loading state should match mutation pending status.
|
||||
@@ -197,13 +208,17 @@ describe('useModifyOperations', () => {
|
||||
// Scenario: successful deletes should close tabs/drafts and emit collaboration updates.
|
||||
describe('Delete success', () => {
|
||||
it('should delete file node, clear descendants and current file tabs, and show file success toast', async () => {
|
||||
const { storeApi, closeTab, clearDraftContent } = createStoreApi()
|
||||
const { storeApi, closeTab, clearDraftContent, clearFileMetadata, clearClipboard, state } = createStoreApi()
|
||||
const onClose = vi.fn()
|
||||
const { node } = createNodeApi('file', 'file-7')
|
||||
const treeData: AppAssetTreeResponse = {
|
||||
children: [createTreeNodeData('root-folder', 'folder')],
|
||||
}
|
||||
mocks.getAllDescendantFileIds.mockReturnValue(['desc-1', 'desc-2'])
|
||||
state.clipboard = {
|
||||
operation: 'cut',
|
||||
nodeIds: new Set(['file-7']),
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useModifyOperations({
|
||||
nodeId: 'file-7',
|
||||
@@ -235,6 +250,10 @@ describe('useModifyOperations', () => {
|
||||
expect(clearDraftContent).toHaveBeenNthCalledWith(1, 'desc-1')
|
||||
expect(clearDraftContent).toHaveBeenNthCalledWith(2, 'desc-2')
|
||||
expect(clearDraftContent).toHaveBeenNthCalledWith(3, 'file-7')
|
||||
expect(clearFileMetadata).toHaveBeenNthCalledWith(1, 'desc-1')
|
||||
expect(clearFileMetadata).toHaveBeenNthCalledWith(2, 'desc-2')
|
||||
expect(clearFileMetadata).toHaveBeenNthCalledWith(3, 'file-7')
|
||||
expect(clearClipboard).toHaveBeenCalledTimes(1)
|
||||
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('workflow.skillSidebar.menu.fileDeleted')
|
||||
expect(result.current.showDeleteConfirm).toBe(false)
|
||||
@@ -242,12 +261,17 @@ describe('useModifyOperations', () => {
|
||||
})
|
||||
|
||||
it('should delete folder node and skip closing the folder tab itself', async () => {
|
||||
const { storeApi, closeTab, clearDraftContent } = createStoreApi()
|
||||
const { storeApi, closeTab, clearDraftContent, clearFileMetadata, clearClipboard, state } = createStoreApi()
|
||||
const { node } = createNodeApi('folder', 'folder-9')
|
||||
const treeData: AppAssetTreeResponse = {
|
||||
children: [createTreeNodeData('root-folder', 'folder')],
|
||||
}
|
||||
mocks.getAllDescendantFileIds.mockReturnValue(['file-in-folder'])
|
||||
mocks.isDescendantOf.mockReturnValueOnce(true)
|
||||
state.clipboard = {
|
||||
operation: 'cut',
|
||||
nodeIds: new Set(['nested-folder']),
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useModifyOperations({
|
||||
nodeId: 'folder-9',
|
||||
@@ -266,6 +290,9 @@ describe('useModifyOperations', () => {
|
||||
expect(closeTab).toHaveBeenCalledWith('file-in-folder')
|
||||
expect(clearDraftContent).toHaveBeenCalledTimes(1)
|
||||
expect(clearDraftContent).toHaveBeenCalledWith('file-in-folder')
|
||||
expect(clearFileMetadata).toHaveBeenCalledTimes(1)
|
||||
expect(clearFileMetadata).toHaveBeenCalledWith('file-in-folder')
|
||||
expect(clearClipboard).toHaveBeenCalledTimes(1)
|
||||
expect(closeTab).not.toHaveBeenCalledWith('folder-9')
|
||||
expect(clearDraftContent).not.toHaveBeenCalledWith('folder-9')
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('workflow.skillSidebar.menu.deleted')
|
||||
@@ -276,7 +303,7 @@ describe('useModifyOperations', () => {
|
||||
describe('Delete errors', () => {
|
||||
it('should show folder delete error toast on failure', async () => {
|
||||
mocks.deleteMutateAsync.mockRejectedValueOnce(new Error('delete failed'))
|
||||
const { storeApi, closeTab, clearDraftContent } = createStoreApi()
|
||||
const { storeApi, closeTab, clearDraftContent, clearFileMetadata, clearClipboard } = createStoreApi()
|
||||
const onClose = vi.fn()
|
||||
const { node } = createNodeApi('folder', 'folder-err')
|
||||
const treeData: AppAssetTreeResponse = {
|
||||
@@ -299,6 +326,8 @@ describe('useModifyOperations', () => {
|
||||
expect(mocks.emitTreeUpdate).not.toHaveBeenCalled()
|
||||
expect(closeTab).not.toHaveBeenCalled()
|
||||
expect(clearDraftContent).not.toHaveBeenCalled()
|
||||
expect(clearFileMetadata).not.toHaveBeenCalled()
|
||||
expect(clearClipboard).not.toHaveBeenCalled()
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('workflow.skillSidebar.menu.deleteError')
|
||||
expect(result.current.showDeleteConfirm).toBe(false)
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import { useDeleteAppAssetNode } from '@/service/use-app-asset'
|
||||
import { getAllDescendantFileIds } from '../../../utils/tree-utils'
|
||||
import { getAllDescendantFileIds, isDescendantOf } from '../../../utils/tree-utils'
|
||||
import { useSkillTreeUpdateEmitter } from '../data/use-skill-tree-collaboration'
|
||||
|
||||
type UseModifyOperationsOptions = {
|
||||
@@ -61,19 +61,31 @@ export function useModifyOperations({
|
||||
const descendantFileIds = treeData?.children
|
||||
? getAllDescendantFileIds(nodeId, treeData.children)
|
||||
: []
|
||||
const affectedFileIds = Array.from(new Set(
|
||||
isFolder ? descendantFileIds : [...descendantFileIds, nodeId],
|
||||
))
|
||||
|
||||
await deleteNodeAsync({ appId, nodeId })
|
||||
emitTreeUpdate()
|
||||
|
||||
descendantFileIds.forEach((fileId) => {
|
||||
affectedFileIds.forEach((fileId) => {
|
||||
storeApi.getState().closeTab(fileId)
|
||||
storeApi.getState().clearDraftContent(fileId)
|
||||
storeApi.getState().clearFileMetadata(fileId)
|
||||
})
|
||||
|
||||
// Also close and clear the node itself if it's a file
|
||||
if (!isFolder) {
|
||||
storeApi.getState().closeTab(nodeId)
|
||||
storeApi.getState().clearDraftContent(nodeId)
|
||||
const clipboard = storeApi.getState().clipboard
|
||||
if (clipboard) {
|
||||
const shouldClearClipboard = [...clipboard.nodeIds].some((clipboardNodeId) => {
|
||||
if (clipboardNodeId === nodeId)
|
||||
return true
|
||||
if (!isFolder || !treeData?.children)
|
||||
return false
|
||||
return isDescendantOf(clipboardNodeId, nodeId, treeData.children)
|
||||
})
|
||||
|
||||
if (shouldClearClipboard)
|
||||
storeApi.getState().clearClipboard()
|
||||
}
|
||||
|
||||
toast.success(
|
||||
|
||||
@@ -35,6 +35,7 @@ type WorkflowStoreState = {
|
||||
nodeIds: Set<string>
|
||||
} | null
|
||||
selectedTreeNodeId: string | null
|
||||
cutNodes: (nodeIds: string[]) => void
|
||||
clearClipboard: () => void
|
||||
}
|
||||
|
||||
@@ -48,6 +49,7 @@ const mocks = vi.hoisted(() => ({
|
||||
workflowState: {
|
||||
clipboard: null,
|
||||
selectedTreeNodeId: null,
|
||||
cutNodes: vi.fn<(nodeIds: string[]) => void>(),
|
||||
clearClipboard: vi.fn<() => void>(),
|
||||
} as WorkflowStoreState,
|
||||
appStoreState: {
|
||||
@@ -123,6 +125,7 @@ describe('usePasteOperation', () => {
|
||||
vi.clearAllMocks()
|
||||
mocks.workflowState.clipboard = null
|
||||
mocks.workflowState.selectedTreeNodeId = null
|
||||
mocks.workflowState.cutNodes = vi.fn()
|
||||
mocks.appStoreState.appDetail = { id: 'app-1' }
|
||||
mocks.movePending = false
|
||||
mocks.moveMutateAsync.mockResolvedValue(undefined)
|
||||
@@ -303,10 +306,37 @@ describe('usePasteOperation', () => {
|
||||
})
|
||||
|
||||
expect(mocks.workflowState.clearClipboard).not.toHaveBeenCalled()
|
||||
expect(mocks.workflowState.cutNodes).not.toHaveBeenCalled()
|
||||
expect(mocks.emitTreeUpdate).not.toHaveBeenCalled()
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('workflow.skillSidebar.menu.moveError')
|
||||
})
|
||||
|
||||
it('should keep failed node ids in clipboard and still refresh tree when paste partially succeeds', async () => {
|
||||
mocks.workflowState.clipboard = {
|
||||
operation: 'cut',
|
||||
nodeIds: new Set(['node-ok', 'node-fail']),
|
||||
}
|
||||
mocks.moveMutateAsync
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error('move failed'))
|
||||
const treeRef = createTreeRef('target')
|
||||
const treeData: AppAssetTreeResponse = {
|
||||
children: [createTreeNode('node-ok', 'file'), createTreeNode('node-fail', 'file')],
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => usePasteOperation({ treeRef, treeData }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handlePaste()
|
||||
})
|
||||
|
||||
expect(mocks.moveMutateAsync).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.workflowState.clearClipboard).not.toHaveBeenCalled()
|
||||
expect(mocks.workflowState.cutNodes).toHaveBeenCalledWith(['node-fail'])
|
||||
expect(mocks.emitTreeUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('workflow.skillSidebar.menu.moveError')
|
||||
})
|
||||
|
||||
it('should prevent re-entrant paste while a paste is in progress', async () => {
|
||||
mocks.workflowState.clipboard = {
|
||||
operation: 'cut',
|
||||
|
||||
@@ -84,20 +84,37 @@ export function usePasteOperation({
|
||||
isPastingRef.current = true
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
nodeIdsArray.map(nodeId =>
|
||||
moveNodeAsync({
|
||||
const results = await Promise.allSettled(
|
||||
nodeIdsArray.map(async (nodeId) => {
|
||||
await moveNodeAsync({
|
||||
appId,
|
||||
nodeId,
|
||||
payload: { parent_id: targetParentId },
|
||||
}),
|
||||
),
|
||||
})
|
||||
return nodeId
|
||||
}),
|
||||
)
|
||||
|
||||
storeApi.getState().clearClipboard()
|
||||
emitTreeUpdate()
|
||||
const succeededNodeIds = results.flatMap(result =>
|
||||
result.status === 'fulfilled' ? [result.value] : [],
|
||||
)
|
||||
const failedNodeIds = results.flatMap((result, index) =>
|
||||
result.status === 'rejected' ? [nodeIdsArray[index]] : [],
|
||||
)
|
||||
|
||||
toast.success(t('skillSidebar.menu.moved'))
|
||||
if (succeededNodeIds.length > 0)
|
||||
emitTreeUpdate()
|
||||
|
||||
if (failedNodeIds.length === 0) {
|
||||
storeApi.getState().clearClipboard()
|
||||
toast.success(t('skillSidebar.menu.moved'))
|
||||
return
|
||||
}
|
||||
|
||||
if (succeededNodeIds.length > 0)
|
||||
storeApi.getState().cutNodes(failedNodeIds)
|
||||
|
||||
toast.error(t('skillSidebar.menu.moveError'))
|
||||
}
|
||||
catch {
|
||||
toast.error(t('skillSidebar.menu.moveError'))
|
||||
|
||||
@@ -133,6 +133,7 @@ describe('SidebarSearchAdd', () => {
|
||||
|
||||
it('should call create handlers when clicking new file and new folder actions', () => {
|
||||
// Arrange
|
||||
mocks.storeState.fileTreeSearchTerm = 'agent'
|
||||
render(<SidebarSearchAdd />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.add/i }))
|
||||
|
||||
@@ -141,6 +142,8 @@ describe('SidebarSearchAdd', () => {
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /workflow\.skillSidebar\.menu\.newFolder/i }))
|
||||
|
||||
// Assert
|
||||
expect(mocks.setFileTreeSearchTerm).toHaveBeenNthCalledWith(1, '')
|
||||
expect(mocks.setFileTreeSearchTerm).toHaveBeenNthCalledWith(2, '')
|
||||
expect(mocks.fileOperations.handleNewFile).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.fileOperations.handleNewFolder).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
@@ -57,6 +57,16 @@ const SidebarSearchAdd = () => {
|
||||
onClose: handleMenuClose,
|
||||
})
|
||||
|
||||
const handleCreateFile = useCallback(() => {
|
||||
storeApi.getState().setFileTreeSearchTerm('')
|
||||
handleNewFile()
|
||||
}, [handleNewFile, storeApi])
|
||||
|
||||
const handleCreateFolder = useCallback(() => {
|
||||
storeApi.getState().setFileTreeSearchTerm('')
|
||||
handleNewFolder()
|
||||
}, [handleNewFolder, storeApi])
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 p-2">
|
||||
<SearchInput
|
||||
@@ -101,14 +111,14 @@ const SidebarSearchAdd = () => {
|
||||
menuType="dropdown"
|
||||
icon={FileAdd}
|
||||
label={t('skillSidebar.menu.newFile')}
|
||||
onClick={handleNewFile}
|
||||
onClick={handleCreateFile}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<MenuItem
|
||||
menuType="dropdown"
|
||||
icon={FolderAdd}
|
||||
label={t('skillSidebar.menu.newFolder')}
|
||||
onClick={handleNewFolder}
|
||||
onClick={handleCreateFolder}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user