@@ -11,10 +11,12 @@ import { nextUntitledFolderName } from '@/app/workspace/[workspaceId]/components
1111import {
1212 folderRowId ,
1313 parseFolderedRowId ,
14+ splitFolderedRowIds ,
1415} from '@/app/workspace/[workspaceId]/components/folders/folder-row-id'
1516import {
1617 buildDescendantIndex ,
1718 buildMoveOptions ,
19+ buildMoveOptionsExcludingSubtrees ,
1820 parseMoveOptionValue ,
1921 ROOT_MOVE_OPTION_VALUE ,
2022} from '@/app/workspace/[workspaceId]/components/folders/move-options'
@@ -331,3 +333,114 @@ describe('folderAncestorChain', () => {
331333 expect ( folderAncestorChain ( 'a' , ( id ) => folders [ id ] ) . map ( ( f ) => f . id ) ) . toEqual ( [ 'b' , 'a' ] )
332334 } )
333335} )
336+
337+ describe ( 'splitFolderedRowIds' , ( ) => {
338+ it ( 'separates folder rows from resource rows' , ( ) => {
339+ const { folderIds, resourceIds } = splitFolderedRowIds ( [
340+ folderRowId ( 'f-1' ) ,
341+ 'res-1' ,
342+ folderRowId ( 'f-2' ) ,
343+ 'res-2' ,
344+ ] )
345+
346+ expect ( folderIds ) . toEqual ( [ 'f-1' , 'f-2' ] )
347+ expect ( resourceIds ) . toEqual ( [ 'res-1' , 'res-2' ] )
348+ } )
349+
350+ it ( 'returns empty lists for an empty selection' , ( ) => {
351+ expect ( splitFolderedRowIds ( [ ] ) ) . toEqual ( { folderIds : [ ] , resourceIds : [ ] } )
352+ } )
353+
354+ it ( 'accepts a Set, which is how a selection is actually held' , ( ) => {
355+ const { folderIds, resourceIds } = splitFolderedRowIds ( new Set ( [ folderRowId ( 'f-1' ) , 'res-1' ] ) )
356+ expect ( folderIds ) . toEqual ( [ 'f-1' ] )
357+ expect ( resourceIds ) . toEqual ( [ 'res-1' ] )
358+ } )
359+ } )
360+
361+ describe ( 'buildMoveOptionsExcludingSubtrees' , ( ) => {
362+ /** `a` holds `a1`, which holds `a1x`; `b` is an unrelated sibling. */
363+ const folders = [ makeFolder ( 'a' ) , makeFolder ( 'a1' , 'a' ) , makeFolder ( 'a1x' , 'a1' ) , makeFolder ( 'b' ) ]
364+ const descendantsByFolderId = buildDescendantIndex ( folders )
365+ const valuesOf = ( nodes : ReturnType < typeof buildMoveOptions > ) : string [ ] =>
366+ nodes . flatMap ( ( node ) => [ node . value , ...valuesOf ( node . children ) ] )
367+
368+ it ( 'offers every folder when nothing is excluded' , ( ) => {
369+ const options = buildMoveOptionsExcludingSubtrees ( {
370+ folders,
371+ rootLabel : 'Root' ,
372+ excludeFolderIds : [ ] ,
373+ descendantsByFolderId,
374+ } )
375+ expect ( valuesOf ( options ) ) . toEqual ( [ ROOT_MOVE_OPTION_VALUE , 'a' , 'a1' , 'a1x' , 'b' ] )
376+ } )
377+
378+ it ( 'excludes a moving folder and its whole subtree, never offering a cycle' , ( ) => {
379+ // The invariant this helper exists to hold: a folder can never be filed into itself or
380+ // anything beneath it, at any depth.
381+ const options = buildMoveOptionsExcludingSubtrees ( {
382+ folders,
383+ rootLabel : 'Root' ,
384+ excludeFolderIds : [ 'a' ] ,
385+ descendantsByFolderId,
386+ } )
387+ expect ( valuesOf ( options ) ) . toEqual ( [ ROOT_MOVE_OPTION_VALUE , 'b' ] )
388+ } )
389+
390+ it ( 'excludes the union of several selected subtrees' , ( ) => {
391+ const options = buildMoveOptionsExcludingSubtrees ( {
392+ folders,
393+ rootLabel : 'Root' ,
394+ excludeFolderIds : [ 'a1' , 'b' ] ,
395+ descendantsByFolderId,
396+ } )
397+ expect ( valuesOf ( options ) ) . toEqual ( [ ROOT_MOVE_OPTION_VALUE , 'a' ] )
398+ } )
399+
400+ it ( 'always keeps the workspace root as a destination' , ( ) => {
401+ const options = buildMoveOptionsExcludingSubtrees ( {
402+ folders,
403+ rootLabel : 'Root' ,
404+ excludeFolderIds : [ 'a' , 'b' ] ,
405+ descendantsByFolderId,
406+ } )
407+ expect ( valuesOf ( options ) ) . toEqual ( [ ROOT_MOVE_OPTION_VALUE ] )
408+ } )
409+ } )
410+
411+ describe ( 'folderBreadcrumbItems drag destinations' , ( ) => {
412+ const chain = [ makeFolder ( 'a' ) , makeFolder ( 'a1' , 'a' ) ]
413+
414+ it ( 'names the folder each crumb points at, so the header can accept a drop on it' , ( ) => {
415+ const items = folderBreadcrumbItems ( {
416+ rootLabel : 'Files' ,
417+ breadcrumbs : chain ,
418+ onNavigate : vi . fn ( ) ,
419+ } )
420+
421+ expect ( items . map ( ( item ) => item . folderId ) ) . toEqual ( [ null , 'a' , 'a1' ] )
422+ } )
423+
424+ it ( 'leaves a trailing crumb without a folder id, so it stays inert' , ( ) => {
425+ const items = folderBreadcrumbItems ( {
426+ rootLabel : 'Files' ,
427+ breadcrumbs : chain ,
428+ onNavigate : vi . fn ( ) ,
429+ trailing : [ { label : 'report.md' , terminal : true } ] ,
430+ } )
431+
432+ expect ( items . at ( - 1 ) ) . toMatchObject ( { label : 'report.md' } )
433+ expect ( items . at ( - 1 ) ?. folderId ) . toBeUndefined ( )
434+ } )
435+
436+ it ( 'gives the root crumb null rather than omitting it — the root is a real destination' , ( ) => {
437+ const items = folderBreadcrumbItems ( {
438+ rootLabel : 'Files' ,
439+ breadcrumbs : [ ] ,
440+ onNavigate : vi . fn ( ) ,
441+ } )
442+
443+ expect ( items ) . toHaveLength ( 1 )
444+ expect ( items [ 0 ] ) . toHaveProperty ( 'folderId' , null )
445+ } )
446+ } )
0 commit comments