Skip to content

Commit 046302a

Browse files
authored
fix(sidebar): stop bubbled dragleave events cancelling an in-progress drag (#6679)
* fix(sidebar): close folders a drag spring-opened, and surface reorder failures * fix(sidebar): stop bubbled dragleave events cancelling an in-progress drag * fix(sidebar): disarm the spring-open timer when a drag ends
1 parent 58b5ee9 commit 046302a

2 files changed

Lines changed: 498 additions & 128 deletions

File tree

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

Lines changed: 260 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ vi.mock('next/navigation', () => ({
99
useParams: () => ({ workspaceId: 'ws-1' }),
1010
}))
1111

12+
/** Kept out of the module graph so this suite does not pull emcn's CSS modules through postcss. */
13+
vi.mock('@sim/emcn', () => ({ toast: { error: vi.fn() } }))
14+
1215
vi.mock('@/hooks/queries/folders', () => ({
1316
useReorderFolders: () => ({ mutateAsync: vi.fn() }),
1417
}))
@@ -29,13 +32,23 @@ vi.mock('@/lib/folders/tree', () => ({
2932
getFolderPath: () => [],
3033
}))
3134

32-
const { mockUseFolderStore } = vi.hoisted(() => {
33-
const folderState = { setExpanded: () => {}, expandedFolders: new Set<string>() }
35+
const { mockUseFolderStore, mockSetExpanded, expandedFolders } = vi.hoisted(() => {
36+
const expanded = new Set<string>()
37+
const setExpanded = vi.fn((folderId: string, isExpanded: boolean) => {
38+
if (isExpanded) expanded.add(folderId)
39+
else expanded.delete(folderId)
40+
})
41+
const folderState = {
42+
setExpanded,
43+
expandedFolders: expanded,
44+
clearSelection: () => {},
45+
clearFolderSelection: () => {},
46+
}
3447
const store = Object.assign(
3548
(selector: (state: typeof folderState) => unknown) => selector(folderState),
3649
{ getState: () => folderState }
3750
)
38-
return { mockUseFolderStore: store }
51+
return { mockUseFolderStore: store, mockSetExpanded: setExpanded, expandedFolders: expanded }
3952
})
4053
vi.mock('@/stores/folders/store', () => ({ useFolderStore: mockUseFolderStore }))
4154

@@ -63,6 +76,64 @@ function fakeDragOverEvent(): unknown {
6376
}
6477
}
6578

79+
/**
80+
* A `dragover` on a folder row. `clientY` sits in the middle band of the 100px rect, which is what
81+
* `calculateFolderDropPosition` reads as "inside" — the position that arms the spring-open timer.
82+
*/
83+
function fakeFolderDragOverEvent(): unknown {
84+
const currentTarget = {
85+
getBoundingClientRect: () => ({ top: 0, bottom: 100, height: 100 }),
86+
}
87+
return {
88+
preventDefault: () => {},
89+
stopPropagation: () => {},
90+
clientY: 50,
91+
target: {},
92+
currentTarget,
93+
}
94+
}
95+
96+
/** A `drop` carrying no selection payload: enough to record the destination, then bail. */
97+
function fakeDropEvent(): unknown {
98+
return {
99+
preventDefault: () => {},
100+
stopPropagation: () => {},
101+
dataTransfer: { getData: () => '' },
102+
}
103+
}
104+
105+
/**
106+
* Registers a scroll container spanning x 0-200, then arms a drop indicator on it. Registration has
107+
* to precede the first dragOver: the listener effect reads the container ref when `isDragging`
108+
* flips, and `setScrollContainer` is a plain ref setter that triggers no re-render of its own.
109+
*/
110+
function armDragOverScrollContainer(): HTMLDivElement {
111+
const scrollContainer = document.createElement('div')
112+
scrollContainer.getBoundingClientRect = () =>
113+
({ left: 0, right: 200, top: 0, bottom: 400 }) as DOMRect
114+
document.body.appendChild(scrollContainer)
115+
act(() => {
116+
latest.setScrollContainer(scrollContainer)
117+
})
118+
act(() => {
119+
latest.createEdgeDropZone('workflow-1', 'before').onDragOver(fakeDragOverEvent() as never)
120+
})
121+
return scrollContainer
122+
}
123+
124+
/** Chrome's `dragleave` shape: bubbles, and always reports a null `relatedTarget`. */
125+
function dispatchBubbledDragLeave(element: HTMLElement, clientX: number) {
126+
act(() => {
127+
const leave = new Event('dragleave', { bubbles: true }) as DragEvent
128+
Object.defineProperties(leave, {
129+
relatedTarget: { value: null },
130+
clientX: { value: clientX },
131+
clientY: { value: 200 },
132+
})
133+
element.dispatchEvent(leave)
134+
})
135+
}
136+
66137
let container: HTMLDivElement
67138
let root: Root
68139

@@ -93,10 +164,10 @@ describe('useDragDrop stranded-drag reset', () => {
93164
container.remove()
94165
vi.unstubAllGlobals()
95166
vi.clearAllMocks()
167+
expandedFolders.clear()
96168
})
97169

98170
it('clears isDragging on a window dragend when no drop fired', () => {
99-
// A drag entering the list flips isDragging on via initDragOver.
100171
act(() => {
101172
latest.createRootDropZone().onDragOver(fakeDragOverEvent() as never)
102173
})
@@ -109,16 +180,200 @@ describe('useDragDrop stranded-drag reset', () => {
109180
expect(latest.isDragging).toBe(false)
110181
})
111182

183+
/**
184+
* `dragleave` bubbles and Chrome nulls its `relatedTarget`, so the container listener sees one
185+
* for every descendant boundary the pointer crosses. Treating those as "left the list" wiped the
186+
* drop indicator mid-drag, and `handleDrop` bails on a null indicator — so a release just after
187+
* crossing a boundary did nothing at all. Nested rows in an expanded folder cross the most
188+
* boundaries, which is why open folders looked like they broke dragging outright.
189+
*/
190+
it('keeps the drop indicator when a bubbled dragleave has no relatedTarget but the pointer is still inside', () => {
191+
const scrollContainer = armDragOverScrollContainer()
192+
expect(latest.dropIndicator).toEqual({
193+
targetId: 'workflow-1',
194+
position: 'before',
195+
folderId: null,
196+
})
197+
198+
// A child row handing off to its sibling: pointer still well inside the list's 0-200 x-range.
199+
dispatchBubbledDragLeave(scrollContainer, 100)
200+
201+
expect(latest.dropIndicator).not.toBeNull()
202+
scrollContainer.remove()
203+
})
204+
205+
/**
206+
* The root drop zone's own `onDragLeave` clears the indicator through `isLeavingElement`, which
207+
* made the same null-`relatedTarget` assumption. Fixing only the container listener would have
208+
* left this second path clearing the indicator on every internal crossing.
209+
*/
210+
it('keeps the drop indicator when the root drop zone sees a relatedTarget-less dragleave inside itself', () => {
211+
const zone = document.createElement('div')
212+
zone.getBoundingClientRect = () => ({ left: 0, right: 200, top: 0, bottom: 400 }) as DOMRect
213+
214+
act(() => {
215+
latest.createEdgeDropZone('workflow-1', 'before').onDragOver(fakeDragOverEvent() as never)
216+
})
217+
expect(latest.dropIndicator).not.toBeNull()
218+
219+
act(() => {
220+
latest.createRootDropZone().onDragLeave({
221+
relatedTarget: null,
222+
currentTarget: zone,
223+
clientX: 100,
224+
clientY: 200,
225+
} as never)
226+
})
227+
228+
expect(latest.dropIndicator).not.toBeNull()
229+
})
230+
231+
it('clears the drop indicator when the pointer genuinely leaves the list', () => {
232+
const scrollContainer = armDragOverScrollContainer()
233+
expect(latest.dropIndicator).not.toBeNull()
234+
235+
dispatchBubbledDragLeave(scrollContainer, 900)
236+
237+
expect(latest.dropIndicator).toBeNull()
238+
scrollContainer.remove()
239+
})
240+
112241
it('keeps isDragging active across dragOver updates until the drag ends', () => {
113242
act(() => {
114243
latest.createRootDropZone().onDragOver(fakeDragOverEvent() as never)
115244
})
116245
expect(latest.isDragging).toBe(true)
117246

118-
// A subsequent dragOver must not tear down the active drag.
119247
act(() => {
120248
latest.createRootDropZone().onDragOver(fakeDragOverEvent() as never)
121249
})
122250
expect(latest.isDragging).toBe(true)
123251
})
124252
})
253+
254+
/**
255+
* Hovering a collapsed folder mid-drag spring-opens it so you can drop inside. Every folder opened
256+
* that way that the drop did NOT land in has to close again, or dragging past a folder silently
257+
* leaves it open and the sidebar grows rows the user never asked to see.
258+
*/
259+
describe('useDragDrop spring-open revert', () => {
260+
beforeEach(() => {
261+
vi.useFakeTimers()
262+
vi.stubGlobal(
263+
'requestAnimationFrame',
264+
() => 0 as unknown as ReturnType<typeof requestAnimationFrame>
265+
)
266+
vi.stubGlobal('cancelAnimationFrame', () => {})
267+
container = document.createElement('div')
268+
document.body.appendChild(container)
269+
root = createRoot(container)
270+
act(() => {
271+
root.render(<Harness />)
272+
})
273+
})
274+
275+
afterEach(() => {
276+
act(() => {
277+
root.unmount()
278+
})
279+
container.remove()
280+
vi.unstubAllGlobals()
281+
vi.useRealTimers()
282+
vi.clearAllMocks()
283+
expandedFolders.clear()
284+
})
285+
286+
/** Drives a drag that lingers over `folder-1` long enough to spring it open. */
287+
function dragOverFolderUntilExpanded() {
288+
act(() => {
289+
latest.handleDragStart(null)
290+
})
291+
act(() => {
292+
latest
293+
.createFolderDragHandlers('folder-1', null)
294+
.onDragOver(fakeFolderDragOverEvent() as never)
295+
})
296+
act(() => {
297+
vi.advanceTimersByTime(500)
298+
})
299+
}
300+
301+
it('closes a folder it spring-opened when the drag ends without dropping into it', () => {
302+
dragOverFolderUntilExpanded()
303+
expect(mockSetExpanded).toHaveBeenCalledWith('folder-1', true)
304+
305+
// Esc-cancel / release outside: `dragend` fires with no drop recorded.
306+
act(() => {
307+
latest.handleDragEnd()
308+
})
309+
310+
expect(mockSetExpanded).toHaveBeenCalledWith('folder-1', false)
311+
expect(expandedFolders.has('folder-1')).toBe(false)
312+
})
313+
314+
it('leaves a folder open when the drop landed inside it', () => {
315+
dragOverFolderUntilExpanded()
316+
mockSetExpanded.mockClear()
317+
318+
// `dragend` fires after every drop, so the revert path runs here too.
319+
act(() => {
320+
void latest.createFolderDragHandlers('folder-1', null).onDrop(fakeDropEvent() as never)
321+
})
322+
act(() => {
323+
latest.handleDragEnd()
324+
})
325+
326+
expect(mockSetExpanded).not.toHaveBeenCalledWith('folder-1', false)
327+
expect(expandedFolders.has('folder-1')).toBe(true)
328+
})
329+
330+
/**
331+
* The spring-open timer is armed for 400ms, so a drag ending just before it fires leaves it
332+
* pending. Relying on the effect cleanup to cancel it would let it land after the drag-end
333+
* collapse had already emptied the set — re-adding the folder for the *next* drag to close, by
334+
* which point the user had opened it themselves.
335+
*/
336+
it('does not spring-open a folder when the drag ends before the timer fires', () => {
337+
act(() => {
338+
latest.handleDragStart(null)
339+
})
340+
act(() => {
341+
latest
342+
.createFolderDragHandlers('folder-1', null)
343+
.onDragOver(fakeFolderDragOverEvent() as never)
344+
})
345+
346+
/**
347+
* Deliberately outside `act`: the race only exists while React has scheduled the drag-end state
348+
* changes but not yet committed them, so the effect cleanup has not run and the timer is still
349+
* armed. Wrapping this in `act` would flush the commit first and cancel the timer via the
350+
* cleanup, hiding the very gap under test.
351+
*/
352+
latest.handleDragEnd()
353+
act(() => {
354+
vi.advanceTimersByTime(500)
355+
})
356+
357+
expect(mockSetExpanded).not.toHaveBeenCalledWith('folder-1', true)
358+
expect(expandedFolders.has('folder-1')).toBe(false)
359+
360+
// Nothing was left behind for a later drag to collapse.
361+
act(() => {
362+
latest.handleDragEnd()
363+
})
364+
expect(mockSetExpanded).not.toHaveBeenCalledWith('folder-1', false)
365+
})
366+
367+
it('never closes a folder the user had already opened themselves', () => {
368+
expandedFolders.add('folder-1')
369+
370+
dragOverFolderUntilExpanded()
371+
act(() => {
372+
latest.handleDragEnd()
373+
})
374+
375+
// Already-expanded folders are skipped by the spring-open effect, so nothing to revert.
376+
expect(mockSetExpanded).not.toHaveBeenCalledWith('folder-1', false)
377+
expect(expandedFolders.has('folder-1')).toBe(true)
378+
})
379+
})

0 commit comments

Comments
 (0)