Skip to content

Commit c6e8aac

Browse files
authored
fix(sidebar): reset drag state on dragend so edge drop zones can't strand over first/last rows (#5774)
1 parent 031a295 commit c6e8aac

2 files changed

Lines changed: 134 additions & 0 deletions

File tree

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act } from 'react'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
vi.mock('next/navigation', () => ({
9+
useParams: () => ({ workspaceId: 'ws-1' }),
10+
}))
11+
12+
vi.mock('@/hooks/queries/folders', () => ({
13+
useReorderFolders: () => ({ mutateAsync: vi.fn() }),
14+
}))
15+
16+
vi.mock('@/hooks/queries/workflows', () => ({
17+
useReorderWorkflows: () => ({ mutateAsync: vi.fn() }),
18+
}))
19+
20+
vi.mock('@/hooks/queries/utils/folder-cache', () => ({
21+
getFolderMap: () => ({}),
22+
}))
23+
24+
vi.mock('@/hooks/queries/utils/workflow-cache', () => ({
25+
getWorkflows: () => [],
26+
}))
27+
28+
vi.mock('@/lib/folders/tree', () => ({
29+
getFolderPath: () => [],
30+
}))
31+
32+
const { mockUseFolderStore } = vi.hoisted(() => {
33+
const folderState = { setExpanded: () => {}, expandedFolders: new Set<string>() }
34+
const store = Object.assign(
35+
(selector: (state: typeof folderState) => unknown) => selector(folderState),
36+
{ getState: () => folderState }
37+
)
38+
return { mockUseFolderStore: store }
39+
})
40+
vi.mock('@/stores/folders/store', () => ({ useFolderStore: mockUseFolderStore }))
41+
42+
import { useDragDrop } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-drag-drop'
43+
44+
type DragDropApi = ReturnType<typeof useDragDrop>
45+
46+
let latest: DragDropApi
47+
48+
function Harness() {
49+
latest = useDragDrop()
50+
return null
51+
}
52+
53+
/** Minimal stand-in for the dragOver event `initDragOver` consumes. */
54+
function fakeDragOverEvent(): unknown {
55+
const node = {}
56+
return {
57+
preventDefault: () => {},
58+
stopPropagation: () => {},
59+
clientY: 0,
60+
// target !== currentTarget so the root drop zone skips indicator math (getBoundingClientRect)
61+
target: node,
62+
currentTarget: {},
63+
}
64+
}
65+
66+
let container: HTMLDivElement
67+
let root: Root
68+
69+
describe('useDragDrop stranded-drag reset', () => {
70+
beforeEach(() => {
71+
// Prevent the auto-scroll rAF loop from spinning in jsdom.
72+
vi.stubGlobal(
73+
'requestAnimationFrame',
74+
() => 0 as unknown as ReturnType<typeof requestAnimationFrame>
75+
)
76+
vi.stubGlobal('cancelAnimationFrame', () => {})
77+
container = document.createElement('div')
78+
document.body.appendChild(container)
79+
root = createRoot(container)
80+
act(() => {
81+
root.render(<Harness />)
82+
})
83+
// The reset listeners only attach once a scroll container is registered.
84+
act(() => {
85+
latest.setScrollContainer(document.createElement('div'))
86+
})
87+
})
88+
89+
afterEach(() => {
90+
act(() => {
91+
root.unmount()
92+
})
93+
container.remove()
94+
vi.unstubAllGlobals()
95+
vi.clearAllMocks()
96+
})
97+
98+
it('clears isDragging on a window dragend when no drop fired', () => {
99+
// A drag entering the list flips isDragging on via initDragOver.
100+
act(() => {
101+
latest.createRootDropZone().onDragOver(fakeDragOverEvent() as never)
102+
})
103+
expect(latest.isDragging).toBe(true)
104+
105+
// The drag is cancelled/dropped outside the list: only `dragend` fires, no `drop`.
106+
act(() => {
107+
window.dispatchEvent(new Event('dragend'))
108+
})
109+
expect(latest.isDragging).toBe(false)
110+
})
111+
112+
it('keeps isDragging active across dragOver updates until the drag ends', () => {
113+
act(() => {
114+
latest.createRootDropZone().onDragOver(fakeDragOverEvent() as never)
115+
})
116+
expect(latest.isDragging).toBe(true)
117+
118+
// A subsequent dragOver must not tear down the active drag.
119+
act(() => {
120+
latest.createRootDropZone().onDragOver(fakeDragOverEvent() as never)
121+
})
122+
expect(latest.isDragging).toBe(true)
123+
})
124+
})

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-drag-drop.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -649,11 +649,21 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
649649
if (target && container.contains(target)) return
650650
handleDragEnd()
651651
}
652+
/**
653+
* `dragend` always fires on the drag source at the end of any drag operation, including
654+
* Esc-cancels and drops on non-droppable targets. Without this reset, a non-sidebar drag
655+
* that entered the list (flipping `isDragging` on via `initDragOver`) but ended without a
656+
* `drop` inside the container would strand `isDragging` at `true` — leaving the absolutely
657+
* positioned edge drop zones mounted over the first/last rows and stealing their grab band.
658+
*/
659+
const onWindowDragEnd = () => handleDragEnd()
652660
container.addEventListener('dragleave', onLeave)
653661
window.addEventListener('drop', onWindowDrop, true)
662+
window.addEventListener('dragend', onWindowDragEnd, true)
654663
return () => {
655664
container.removeEventListener('dragleave', onLeave)
656665
window.removeEventListener('drop', onWindowDrop, true)
666+
window.removeEventListener('dragend', onWindowDragEnd, true)
657667
}
658668
}, [isDragging, handleDragEnd])
659669

0 commit comments

Comments
 (0)