@@ -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+
1215vi . 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} )
4053vi . 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+
66137let container : HTMLDivElement
67138let 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