mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(sidebar): reset drag state on dragend so edge drop zones can't strand over first/last rows (#5774)
This commit is contained in:
+124
@@ -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<string>() }
|
||||
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<typeof useDragDrop>
|
||||
|
||||
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<typeof requestAnimationFrame>
|
||||
)
|
||||
vi.stubGlobal('cancelAnimationFrame', () => {})
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
act(() => {
|
||||
root.render(<Harness />)
|
||||
})
|
||||
// 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)
|
||||
})
|
||||
})
|
||||
@@ -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])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user