Skip to content

Commit 4b4931b

Browse files
committed
fix(reorder): drag and drop hook
1 parent 35411e4 commit 4b4931b

File tree

3 files changed

+120
-65
lines changed

3 files changed

+120
-65
lines changed

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

Lines changed: 114 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ const logger = createLogger('WorkflowList:DragDrop')
1313
const SCROLL_THRESHOLD = 60
1414
const SCROLL_SPEED = 8
1515
const HOVER_EXPAND_DELAY = 400
16-
const DRAG_OVER_THROTTLE_MS = 16
1716

1817
export interface DropIndicator {
1918
targetId: string
@@ -32,21 +31,35 @@ type SiblingItem = {
3231
createdAt: Date
3332
}
3433

34+
/** Root folder vs root workflow scope: API/cache may use null or undefined for "no parent". */
35+
function isSameFolderScope(
36+
parentOrFolderId: string | null | undefined,
37+
scope: string | null
38+
): boolean {
39+
return (parentOrFolderId ?? null) === (scope ?? null)
40+
}
41+
3542
export function useDragDrop(options: UseDragDropOptions = {}) {
3643
const { disabled = false } = options
3744
const [dropIndicator, setDropIndicator] = useState<DropIndicator | null>(null)
45+
/**
46+
* Mirrors `dropIndicator` synchronously. `drop` can fire before React commits the last
47+
* `dragOver` state update, so `handleDrop` must read this ref instead of state.
48+
*/
49+
const dropIndicatorRef = useRef<DropIndicator | null>(null)
3850
const [isDragging, setIsDragging] = useState(false)
3951
const [hoverFolderId, setHoverFolderId] = useState<string | null>(null)
4052
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
4153
const scrollAnimationRef = useRef<number | null>(null)
4254
const hoverExpandTimerRef = useRef<number | null>(null)
4355
const lastDragYRef = useRef<number>(0)
44-
const lastDragOverTimeRef = useRef<number>(0)
4556
const draggedSourceFolderRef = useRef<string | null>(null)
4657
const siblingsCacheRef = useRef<Map<string, SiblingItem[]>>(new Map())
58+
const isDraggingRef = useRef(false)
4759

4860
const params = useParams()
4961
const workspaceId = params.workspaceId as string | undefined
62+
5063
const reorderWorkflowsMutation = useReorderWorkflows()
5164
const reorderFoldersMutation = useReorderFolders()
5265
const setExpanded = useFolderStore((s) => s.setExpanded)
@@ -127,6 +140,10 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
127140
}
128141
}, [hoverFolderId, isDragging, expandedFolders, setExpanded])
129142

143+
useEffect(() => {
144+
siblingsCacheRef.current.clear()
145+
}, [workspaceId])
146+
130147
const calculateDropPosition = useCallback(
131148
(e: React.DragEvent, element: HTMLElement): 'before' | 'after' => {
132149
const rect = element.getBoundingClientRect()
@@ -164,12 +181,28 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
164181
: indicator.folderId
165182
}, [])
166183

167-
const calculateInsertIndex = useCallback(
168-
(remaining: SiblingItem[], indicator: DropIndicator): number => {
169-
return indicator.position === 'inside'
170-
? remaining.length
171-
: remaining.findIndex((item) => item.id === indicator.targetId) +
172-
(indicator.position === 'after' ? 1 : 0)
184+
/**
185+
* Insert index into the list of siblings **excluding** moving items. Must use the full
186+
* `siblingItems` list for lookup: when the drop line targets the dragged row,
187+
* `indicator.targetId` is not present in `remaining`, so indexing `remaining` alone
188+
* returns -1 and corrupts the splice.
189+
*/
190+
const getInsertIndexInRemaining = useCallback(
191+
(siblingItems: SiblingItem[], movingIds: Set<string>, indicator: DropIndicator): number => {
192+
if (indicator.position === 'inside') {
193+
return siblingItems.filter((s) => !movingIds.has(s.id)).length
194+
}
195+
196+
const targetIdx = siblingItems.findIndex((s) => s.id === indicator.targetId)
197+
if (targetIdx === -1) {
198+
return siblingItems.filter((s) => !movingIds.has(s.id)).length
199+
}
200+
201+
if (indicator.position === 'before') {
202+
return siblingItems.slice(0, targetIdx).filter((s) => !movingIds.has(s.id)).length
203+
}
204+
205+
return siblingItems.slice(0, targetIdx + 1).filter((s) => !movingIds.has(s.id)).length
173206
},
174207
[]
175208
)
@@ -217,57 +250,65 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
217250
lastDragYRef.current = e.clientY
218251

219252
if (!isDragging) {
253+
isDraggingRef.current = true
220254
setIsDragging(true)
221255
}
222256

223-
const now = performance.now()
224-
if (now - lastDragOverTimeRef.current < DRAG_OVER_THROTTLE_MS) {
225-
return false
226-
}
227-
lastDragOverTimeRef.current = now
228257
return true
229258
},
230259
[isDragging]
231260
)
232261

233-
const getSiblingItems = useCallback((folderId: string | null): SiblingItem[] => {
234-
const cacheKey = folderId ?? 'root'
235-
const cached = siblingsCacheRef.current.get(cacheKey)
236-
if (cached) return cached
237-
238-
const currentFolders = workspaceId ? getFolderMap(workspaceId) : {}
239-
const currentWorkflows = workspaceId ? getWorkflows(workspaceId) : []
240-
const siblings = [
241-
...Object.values(currentFolders)
242-
.filter((f) => f.parentId === folderId)
243-
.map((f) => ({
244-
type: 'folder' as const,
245-
id: f.id,
246-
sortOrder: f.sortOrder,
247-
createdAt: f.createdAt,
248-
})),
249-
...currentWorkflows
250-
.filter((w) => w.folderId === folderId)
251-
.map((w) => ({
252-
type: 'workflow' as const,
253-
id: w.id,
254-
sortOrder: w.sortOrder,
255-
createdAt: w.createdAt,
256-
})),
257-
].sort(compareSiblingItems)
258-
259-
siblingsCacheRef.current.set(cacheKey, siblings)
260-
return siblings
261-
}, [])
262+
const getSiblingItems = useCallback(
263+
(folderId: string | null): SiblingItem[] => {
264+
const cacheKey = folderId ?? 'root'
265+
if (!isDraggingRef.current) {
266+
const cached = siblingsCacheRef.current.get(cacheKey)
267+
if (cached) return cached
268+
}
269+
270+
const currentFolders = workspaceId ? getFolderMap(workspaceId) : {}
271+
const currentWorkflows = workspaceId ? getWorkflows(workspaceId) : []
272+
const siblings = [
273+
...Object.values(currentFolders)
274+
.filter((f) => isSameFolderScope(f.parentId, folderId))
275+
.map((f) => ({
276+
type: 'folder' as const,
277+
id: f.id,
278+
sortOrder: f.sortOrder,
279+
createdAt: f.createdAt,
280+
})),
281+
...currentWorkflows
282+
.filter((w) => isSameFolderScope(w.folderId, folderId))
283+
.map((w) => ({
284+
type: 'workflow' as const,
285+
id: w.id,
286+
sortOrder: w.sortOrder,
287+
createdAt: w.createdAt,
288+
})),
289+
].sort(compareSiblingItems)
290+
291+
if (!isDraggingRef.current) {
292+
siblingsCacheRef.current.set(cacheKey, siblings)
293+
}
294+
return siblings
295+
},
296+
[workspaceId]
297+
)
262298

263299
const setNormalizedDropIndicator = useCallback(
264300
(indicator: DropIndicator | null) => {
265-
setDropIndicator((prev) => {
266-
let next: DropIndicator | null = indicator
301+
if (indicator === null) {
302+
dropIndicatorRef.current = null
303+
setDropIndicator(null)
304+
return
305+
}
267306

268-
if (indicator && indicator.position === 'after' && indicator.targetId !== 'root') {
269-
const siblings = getSiblingItems(indicator.folderId)
270-
const currentIdx = siblings.findIndex((s) => s.id === indicator.targetId)
307+
let next: DropIndicator = indicator
308+
if (indicator.position === 'after' && indicator.targetId !== 'root') {
309+
const siblings = getSiblingItems(indicator.folderId)
310+
const currentIdx = siblings.findIndex((s) => s.id === indicator.targetId)
311+
if (currentIdx !== -1) {
271312
const nextSibling = siblings[currentIdx + 1]
272313
if (nextSibling) {
273314
next = {
@@ -277,15 +318,18 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
277318
}
278319
}
279320
}
321+
}
280322

323+
setDropIndicator((prev) => {
281324
if (
282-
prev?.targetId === next?.targetId &&
283-
prev?.position === next?.position &&
284-
prev?.folderId === next?.folderId
325+
prev?.targetId === next.targetId &&
326+
prev?.position === next.position &&
327+
prev?.folderId === next.folderId
285328
) {
329+
dropIndicatorRef.current = prev
286330
return prev
287331
}
288-
332+
dropIndicatorRef.current = next
289333
return next
290334
})
291335
},
@@ -324,7 +368,7 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
324368
sortOrder: workflow.sortOrder,
325369
createdAt: workflow.createdAt,
326370
}
327-
if (workflow.folderId === destinationFolderId) {
371+
if (isSameFolderScope(workflow.folderId, destinationFolderId)) {
328372
fromDestination.push(item)
329373
} else {
330374
fromOther.push(item)
@@ -340,7 +384,7 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
340384
sortOrder: folder.sortOrder,
341385
createdAt: folder.createdAt,
342386
}
343-
if (folder.parentId === destinationFolderId) {
387+
if (isSameFolderScope(folder.parentId, destinationFolderId)) {
344388
fromDestination.push(item)
345389
} else {
346390
fromOther.push(item)
@@ -352,7 +396,7 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
352396

353397
return { fromDestination, fromOther }
354398
},
355-
[]
399+
[workspaceId]
356400
)
357401

358402
const handleSelectionDrop = useCallback(
@@ -365,7 +409,9 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
365409
try {
366410
const destinationFolderId = getDestinationFolderId(indicator)
367411
const validFolderIds = folderIds.filter((id) => canMoveFolderTo(id, destinationFolderId))
368-
if (workflowIds.length === 0 && validFolderIds.length === 0) return
412+
if (workflowIds.length === 0 && validFolderIds.length === 0) {
413+
return
414+
}
369415

370416
const siblingItems = getSiblingItems(destinationFolderId)
371417
const movingIds = new Set([...workflowIds, ...validFolderIds])
@@ -377,7 +423,7 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
377423
destinationFolderId
378424
)
379425

380-
const insertAt = calculateInsertIndex(remaining, indicator)
426+
const insertAt = getInsertIndexInRemaining(siblingItems, movingIds, indicator)
381427
const newOrder = [
382428
...remaining.slice(0, insertAt),
383429
...fromDestination,
@@ -400,7 +446,7 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
400446
canMoveFolderTo,
401447
getSiblingItems,
402448
collectMovingItems,
403-
calculateInsertIndex,
449+
getInsertIndexInRemaining,
404450
buildAndSubmitUpdates,
405451
]
406452
)
@@ -410,8 +456,10 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
410456
e.preventDefault()
411457
e.stopPropagation()
412458

413-
const indicator = dropIndicator
459+
const indicator = dropIndicatorRef.current
460+
dropIndicatorRef.current = null
414461
setDropIndicator(null)
462+
isDraggingRef.current = false
415463
setIsDragging(false)
416464
siblingsCacheRef.current.clear()
417465

@@ -430,7 +478,7 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
430478
logger.error('Failed to handle drop:', error)
431479
}
432480
},
433-
[dropIndicator, handleSelectionDrop]
481+
[handleSelectionDrop]
434482
)
435483

436484
const createWorkflowDragHandlers = useCallback(
@@ -538,7 +586,9 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
538586
onDragOver: (e: React.DragEvent<HTMLElement>) => {
539587
if (!initDragOver(e)) return
540588
if (itemId) {
541-
setDropIndicator({ targetId: itemId, position, folderId: null })
589+
const edge: DropIndicator = { targetId: itemId, position, folderId: null }
590+
dropIndicatorRef.current = edge
591+
setDropIndicator(edge)
542592
} else {
543593
setNormalizedDropIndicator({ targetId: 'root', position: 'inside', folderId: null })
544594
}
@@ -551,11 +601,15 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
551601

552602
const handleDragStart = useCallback((sourceFolderId: string | null) => {
553603
draggedSourceFolderRef.current = sourceFolderId
604+
siblingsCacheRef.current.clear()
605+
isDraggingRef.current = true
554606
setIsDragging(true)
555607
}, [])
556608

557609
const handleDragEnd = useCallback(() => {
610+
isDraggingRef.current = false
558611
setIsDragging(false)
612+
dropIndicatorRef.current = null
559613
setDropIndicator(null)
560614
draggedSourceFolderRef.current = null
561615
setHoverFolderId(null)

apps/sim/hooks/queries/folders.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ function mapFolder(folder: any): WorkflowFolder {
1919
name: folder.name,
2020
userId: folder.userId,
2121
workspaceId: folder.workspaceId,
22-
parentId: folder.parentId,
22+
parentId: folder.parentId ?? null,
2323
color: folder.color,
2424
isExpanded: folder.isExpanded,
2525
sortOrder: folder.sortOrder,
@@ -332,8 +332,9 @@ export function useReorderFolders() {
332332
)
333333

334334
const updatesById = new Map(variables.updates.map((update) => [update.id, update]))
335-
queryClient.setQueryData<WorkflowFolder[]>(folderKeys.list(variables.workspaceId), (old) =>
336-
(old ?? []).map((folder) => {
335+
queryClient.setQueryData<WorkflowFolder[]>(folderKeys.list(variables.workspaceId), (old) => {
336+
if (!old?.length) return old
337+
return old.map((folder) => {
337338
const update = updatesById.get(folder.id)
338339
if (!update) return folder
339340
return {
@@ -342,7 +343,7 @@ export function useReorderFolders() {
342343
parentId: update.parentId !== undefined ? update.parentId : folder.parentId,
343344
}
344345
})
345-
)
346+
})
346347

347348
return { snapshot }
348349
},

apps/sim/hooks/queries/utils/workflow-list-query.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export function mapWorkflow(workflow: WorkflowApiRow): WorkflowMetadata {
2424
description: workflow.description ?? undefined,
2525
color: workflow.color,
2626
workspaceId: workflow.workspaceId,
27-
folderId: workflow.folderId ?? undefined,
27+
folderId: workflow.folderId ?? null,
2828
sortOrder: workflow.sortOrder ?? 0,
2929
createdAt: new Date(workflow.createdAt),
3030
lastModified: new Date(workflow.updatedAt || workflow.createdAt),

0 commit comments

Comments
 (0)