Align skill tree menu behaviors

This commit is contained in:
yyh
2026-03-24 23:21:24 +08:00
parent 612d90ac6f
commit 84005bd25b
11 changed files with 313 additions and 158 deletions

View File

@ -18,6 +18,8 @@ type UseFileOperationsOptions = {
onClose: () => void
treeRef?: React.RefObject<TreeApi<TreeNodeData> | null>
node?: NodeApi<TreeNodeData>
nodeType?: TreeNodeData['node_type']
fileName?: string
}
export function useFileOperations({
@ -25,8 +27,12 @@ export function useFileOperations({
onClose,
treeRef,
node,
nodeType: explicitNodeType,
fileName: explicitFileName,
}: UseFileOperationsOptions) {
const nodeId = node?.data.id ?? explicitNodeId ?? ''
const nodeType = node?.data.node_type ?? explicitNodeType
const fileName = node?.data.name ?? explicitFileName
const appDetail = useAppStore(s => s.appDetail)
const appId = appDetail?.id || ''
@ -46,6 +52,7 @@ export function useFileOperations({
nodeId,
node,
treeRef,
nodeType,
appId,
storeApi,
treeData,
@ -55,7 +62,7 @@ export function useFileOperations({
const downloadOps = useDownloadOperation({
appId,
nodeId,
fileName: node?.data.name,
fileName,
onClose,
})

View File

@ -18,6 +18,7 @@ type UseModifyOperationsOptions = {
nodeId: string
node?: NodeApi<TreeNodeData>
treeRef?: React.RefObject<TreeApi<TreeNodeData> | null>
nodeType?: TreeNodeData['node_type']
appId: string
storeApi: StoreApi<SkillEditorSliceShape>
treeData?: AppAssetTreeResponse
@ -28,6 +29,7 @@ export function useModifyOperations({
nodeId,
node,
treeRef,
nodeType,
appId,
storeApi,
treeData,
@ -54,7 +56,7 @@ export function useModifyOperations({
}, [])
const handleDeleteConfirm = useCallback(async () => {
const isFolder = node?.data?.node_type === 'folder'
const isFolder = (node?.data?.node_type ?? nodeType) === 'folder'
try {
const descendantFileIds = treeData?.children
? getAllDescendantFileIds(nodeId, treeData.children)
@ -91,7 +93,7 @@ export function useModifyOperations({
setShowDeleteConfirm(false)
onClose()
}
}, [appId, nodeId, node?.data?.node_type, deleteNodeAsync, storeApi, treeData?.children, onClose, t, emitTreeUpdate])
}, [appId, nodeId, node?.data?.node_type, nodeType, deleteNodeAsync, storeApi, treeData?.children, onClose, t, emitTreeUpdate])
const handleDeleteCancel = useCallback(() => {
setShowDeleteConfirm(false)

View File

@ -61,6 +61,7 @@ const mocks = vi.hoisted(() => ({
getTargetFolderIdFromSelection: vi.fn<(selectedId: string | null, nodes: TreeNodeData[]) => string>(),
toApiParentId: vi.fn<(folderId: string | null | undefined) => string | null>(),
findNodeById: vi.fn<(nodes: TreeNodeData[], nodeId: string) => TreeNodeData | null>(),
isDescendantOf: vi.fn<(potentialDescendantId: string | null | undefined, ancestorId: string | null | undefined, nodes: TreeNodeData[]) => boolean>(),
}))
vi.mock('@/app/components/workflow/store', () => ({
@ -88,6 +89,7 @@ vi.mock('../../../utils/tree-utils', () => ({
getTargetFolderIdFromSelection: mocks.getTargetFolderIdFromSelection,
toApiParentId: mocks.toApiParentId,
findNodeById: mocks.findNodeById,
isDescendantOf: mocks.isDescendantOf,
}))
vi.mock('@/app/components/base/ui/toast', () => ({
@ -127,6 +129,7 @@ describe('usePasteOperation', () => {
mocks.getTargetFolderIdFromSelection.mockReturnValue('target-folder')
mocks.toApiParentId.mockReturnValue('target-parent')
mocks.findNodeById.mockReturnValue(null)
mocks.isDescendantOf.mockReturnValue(false)
})
// Scenario: isPasting output should reflect mutation pending state.
@ -193,6 +196,30 @@ describe('usePasteOperation', () => {
expect(mocks.moveMutateAsync).not.toHaveBeenCalled()
expect(mocks.toastError).toHaveBeenCalledWith('workflow.skillSidebar.menu.cannotMoveToSelf')
})
it('should reject moving folder into its descendant and show error toast', async () => {
mocks.workflowState.clipboard = {
operation: 'cut',
nodeIds: new Set(['folder-1']),
}
mocks.getTargetFolderIdFromSelection.mockReturnValueOnce('child-folder')
mocks.findNodeById
.mockReturnValueOnce(createTreeNode('folder-1', 'folder'))
.mockReturnValueOnce(createTreeNode('folder-1', 'folder'))
mocks.isDescendantOf.mockReturnValueOnce(true)
const treeRef = createTreeRef('child-folder')
const treeData: AppAssetTreeResponse = {
children: [createTreeNode('folder-1', 'folder')],
}
const { result } = renderHook(() => usePasteOperation({ treeRef, treeData }))
await act(async () => {
await result.current.handlePaste()
})
expect(mocks.moveMutateAsync).not.toHaveBeenCalled()
expect(mocks.toastError).toHaveBeenCalledWith('workflow.skillSidebar.menu.cannotMoveToDescendant')
})
})
// Scenario: successful cut-paste should move all nodes and clear clipboard.

View File

@ -10,7 +10,7 @@ import { useStore as useAppStore } from '@/app/components/app/store'
import { toast } from '@/app/components/base/ui/toast'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { useMoveAppAssetNode } from '@/service/use-app-asset'
import { findNodeById, getTargetFolderIdFromSelection, toApiParentId } from '../../../utils/tree-utils'
import { findNodeById, getTargetFolderIdFromSelection, isDescendantOf, toApiParentId } from '../../../utils/tree-utils'
import { useSkillTreeUpdateEmitter } from '../data/use-skill-tree-collaboration'
type UsePasteOperationOptions = {
@ -64,11 +64,23 @@ export function usePasteOperation({
return false
})
const isMovingToDescendant = nodeIdsArray.some((nodeId) => {
const node = findNodeById(treeChildren, nodeId)
if (!node || node.node_type !== 'folder')
return false
return isDescendantOf(targetFolderId, nodeId, treeChildren)
})
if (isMovingToSelf) {
toast.error(t('skillSidebar.menu.cannotMoveToSelf'))
return
}
if (isMovingToDescendant) {
toast.error(t('skillSidebar.menu.cannotMoveToDescendant'))
return
}
isPastingRef.current = true
try {