diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-drag-drop.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-drag-drop.test.tsx new file mode 100644 index 00000000000..9a84d924f5f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-drag-drop.test.tsx @@ -0,0 +1,124 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('next/navigation', () => ({ + useParams: () => ({ workspaceId: 'ws-1' }), +})) + +vi.mock('@/hooks/queries/folders', () => ({ + useReorderFolders: () => ({ mutateAsync: vi.fn() }), +})) + +vi.mock('@/hooks/queries/workflows', () => ({ + useReorderWorkflows: () => ({ mutateAsync: vi.fn() }), +})) + +vi.mock('@/hooks/queries/utils/folder-cache', () => ({ + getFolderMap: () => ({}), +})) + +vi.mock('@/hooks/queries/utils/workflow-cache', () => ({ + getWorkflows: () => [], +})) + +vi.mock('@/lib/folders/tree', () => ({ + getFolderPath: () => [], +})) + +const { mockUseFolderStore } = vi.hoisted(() => { + const folderState = { setExpanded: () => {}, expandedFolders: new Set() } + const store = Object.assign( + (selector: (state: typeof folderState) => unknown) => selector(folderState), + { getState: () => folderState } + ) + return { mockUseFolderStore: store } +}) +vi.mock('@/stores/folders/store', () => ({ useFolderStore: mockUseFolderStore })) + +import { useDragDrop } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-drag-drop' + +type DragDropApi = ReturnType + +let latest: DragDropApi + +function Harness() { + latest = useDragDrop() + return null +} + +/** Minimal stand-in for the dragOver event `initDragOver` consumes. */ +function fakeDragOverEvent(): unknown { + const node = {} + return { + preventDefault: () => {}, + stopPropagation: () => {}, + clientY: 0, + // target !== currentTarget so the root drop zone skips indicator math (getBoundingClientRect) + target: node, + currentTarget: {}, + } +} + +let container: HTMLDivElement +let root: Root + +describe('useDragDrop stranded-drag reset', () => { + beforeEach(() => { + // Prevent the auto-scroll rAF loop from spinning in jsdom. + vi.stubGlobal( + 'requestAnimationFrame', + () => 0 as unknown as ReturnType + ) + vi.stubGlobal('cancelAnimationFrame', () => {}) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => { + root.render() + }) + // The reset listeners only attach once a scroll container is registered. + act(() => { + latest.setScrollContainer(document.createElement('div')) + }) + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + vi.unstubAllGlobals() + vi.clearAllMocks() + }) + + it('clears isDragging on a window dragend when no drop fired', () => { + // A drag entering the list flips isDragging on via initDragOver. + act(() => { + latest.createRootDropZone().onDragOver(fakeDragOverEvent() as never) + }) + expect(latest.isDragging).toBe(true) + + // The drag is cancelled/dropped outside the list: only `dragend` fires, no `drop`. + act(() => { + window.dispatchEvent(new Event('dragend')) + }) + expect(latest.isDragging).toBe(false) + }) + + it('keeps isDragging active across dragOver updates until the drag ends', () => { + act(() => { + latest.createRootDropZone().onDragOver(fakeDragOverEvent() as never) + }) + expect(latest.isDragging).toBe(true) + + // A subsequent dragOver must not tear down the active drag. + act(() => { + latest.createRootDropZone().onDragOver(fakeDragOverEvent() as never) + }) + expect(latest.isDragging).toBe(true) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-drag-drop.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-drag-drop.ts index 1fb161c813d..3b97ca3caf5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-drag-drop.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-drag-drop.ts @@ -649,11 +649,21 @@ export function useDragDrop(options: UseDragDropOptions = {}) { if (target && container.contains(target)) return handleDragEnd() } + /** + * `dragend` always fires on the drag source at the end of any drag operation, including + * Esc-cancels and drops on non-droppable targets. Without this reset, a non-sidebar drag + * that entered the list (flipping `isDragging` on via `initDragOver`) but ended without a + * `drop` inside the container would strand `isDragging` at `true` — leaving the absolutely + * positioned edge drop zones mounted over the first/last rows and stealing their grab band. + */ + const onWindowDragEnd = () => handleDragEnd() container.addEventListener('dragleave', onLeave) window.addEventListener('drop', onWindowDrop, true) + window.addEventListener('dragend', onWindowDragEnd, true) return () => { container.removeEventListener('dragleave', onLeave) window.removeEventListener('drop', onWindowDrop, true) + window.removeEventListener('dragend', onWindowDragEnd, true) } }, [isDragging, handleDragEnd])