Skip to content

Commit e5edb98

Browse files
authored
improvement(folders): added multi-select for moving folders (#493)
* added multi-select for folders * allow drag into root * remove extraneous comments * instantly create worfklow on plus * styling improvements, fixed flicker * small improvement to dragover container * ack PR comments
1 parent 535a9d3 commit e5edb98

File tree

5 files changed

+565
-333
lines changed

5 files changed

+565
-333
lines changed

apps/sim/app/w/components/sidebar/components/create-menu/create-menu.tsx

Lines changed: 54 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,10 @@ import { useState } from 'react'
44
import { File, Folder, Plus } from 'lucide-react'
55
import { Button } from '@/components/ui/button'
66
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
7-
import {
8-
DropdownMenu,
9-
DropdownMenuContent,
10-
DropdownMenuItem,
11-
DropdownMenuTrigger,
12-
} from '@/components/ui/dropdown-menu'
137
import { Input } from '@/components/ui/input'
148
import { Label } from '@/components/ui/label'
9+
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
10+
import { cn } from '@/lib/utils'
1511
import { useFolderStore } from '@/stores/folders/store'
1612
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
1713

@@ -24,15 +20,18 @@ export function CreateMenu({ onCreateWorkflow, isCollapsed }: CreateMenuProps) {
2420
const [showFolderDialog, setShowFolderDialog] = useState(false)
2521
const [folderName, setFolderName] = useState('')
2622
const [isCreating, setIsCreating] = useState(false)
23+
const [isHoverOpen, setIsHoverOpen] = useState(false)
2724

2825
const { activeWorkspaceId } = useWorkflowRegistry()
2926
const { createFolder } = useFolderStore()
3027

3128
const handleCreateWorkflow = () => {
29+
setIsHoverOpen(false)
3230
onCreateWorkflow()
3331
}
3432

3533
const handleCreateFolder = () => {
34+
setIsHoverOpen(false)
3635
setShowFolderDialog(true)
3736
}
3837

@@ -50,7 +49,6 @@ export function CreateMenu({ onCreateWorkflow, isCollapsed }: CreateMenuProps) {
5049
setShowFolderDialog(false)
5150
} catch (error) {
5251
console.error('Failed to create folder:', error)
53-
// You could add toast notification here
5452
} finally {
5553
setIsCreating(false)
5654
}
@@ -61,81 +59,61 @@ export function CreateMenu({ onCreateWorkflow, isCollapsed }: CreateMenuProps) {
6159
setShowFolderDialog(false)
6260
}
6361

64-
if (isCollapsed) {
65-
return (
66-
<>
67-
<DropdownMenu>
68-
<DropdownMenuTrigger asChild>
69-
<Button variant='ghost' size='icon' className='h-6 w-6 shrink-0 p-0' title='Create'>
70-
<Plus className='h-[18px] w-[18px] stroke-[2px]' />
71-
<span className='sr-only'>Create</span>
72-
</Button>
73-
</DropdownMenuTrigger>
74-
<DropdownMenuContent align='center' side='right'>
75-
<DropdownMenuItem onClick={handleCreateWorkflow}>
76-
<File className='mr-2 h-4 w-4' />
77-
New Workflow
78-
</DropdownMenuItem>
79-
<DropdownMenuItem onClick={handleCreateFolder}>
80-
<Folder className='mr-2 h-4 w-4' />
81-
New Folder
82-
</DropdownMenuItem>
83-
</DropdownMenuContent>
84-
</DropdownMenu>
85-
86-
{/* Folder creation dialog */}
87-
<Dialog open={showFolderDialog} onOpenChange={setShowFolderDialog}>
88-
<DialogContent className='sm:max-w-[425px]'>
89-
<DialogHeader>
90-
<DialogTitle>Create New Folder</DialogTitle>
91-
</DialogHeader>
92-
<form onSubmit={handleFolderSubmit} className='space-y-4'>
93-
<div className='space-y-2'>
94-
<Label htmlFor='folder-name'>Folder Name</Label>
95-
<Input
96-
id='folder-name'
97-
value={folderName}
98-
onChange={(e) => setFolderName(e.target.value)}
99-
placeholder='Enter folder name...'
100-
autoFocus
101-
required
102-
/>
103-
</div>
104-
<div className='flex justify-end space-x-2'>
105-
<Button type='button' variant='outline' onClick={handleCancel}>
106-
Cancel
107-
</Button>
108-
<Button type='submit' disabled={!folderName.trim() || isCreating}>
109-
{isCreating ? 'Creating...' : 'Create Folder'}
110-
</Button>
111-
</div>
112-
</form>
113-
</DialogContent>
114-
</Dialog>
115-
</>
116-
)
117-
}
118-
11962
return (
12063
<>
121-
<DropdownMenu>
122-
<DropdownMenuTrigger asChild>
123-
<Button variant='ghost' size='icon' className='h-6 w-6 shrink-0 p-0' title='Create'>
124-
<Plus className='h-[16px] w-[16px] stroke-[2px]' />
64+
<Popover open={isHoverOpen}>
65+
<PopoverTrigger asChild>
66+
<Button
67+
variant='ghost'
68+
size='icon'
69+
className='h-6 w-6 shrink-0 p-0 focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
70+
title='Create'
71+
onClick={handleCreateWorkflow}
72+
onMouseEnter={() => setIsHoverOpen(true)}
73+
onMouseLeave={() => setIsHoverOpen(false)}
74+
>
75+
<Plus
76+
className={cn(
77+
'stroke-[2px]',
78+
isCollapsed ? 'h-[18px] w-[18px]' : 'h-[16px] w-[16px]'
79+
)}
80+
/>
12581
<span className='sr-only'>Create</span>
12682
</Button>
127-
</DropdownMenuTrigger>
128-
<DropdownMenuContent align='end'>
129-
<DropdownMenuItem onClick={handleCreateWorkflow}>
130-
<File className='mr-2 h-4 w-4' />
83+
</PopoverTrigger>
84+
<PopoverContent
85+
align={isCollapsed ? 'center' : 'end'}
86+
side={isCollapsed ? 'right' : undefined}
87+
sideOffset={0}
88+
className={cn(
89+
'fade-in-0 zoom-in-95 data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
90+
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
91+
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
92+
'z-50 animate-in overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md',
93+
'data-[state=closed]:animate-out',
94+
'w-40'
95+
)}
96+
onMouseEnter={() => setIsHoverOpen(true)}
97+
onMouseLeave={() => setIsHoverOpen(false)}
98+
onOpenAutoFocus={(e) => e.preventDefault()}
99+
onCloseAutoFocus={(e) => e.preventDefault()}
100+
>
101+
<button
102+
className='flex w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground'
103+
onClick={handleCreateWorkflow}
104+
>
105+
<File className='h-4 w-4' />
131106
New Workflow
132-
</DropdownMenuItem>
133-
<DropdownMenuItem onClick={handleCreateFolder}>
134-
<Folder className='mr-2 h-4 w-4' />
107+
</button>
108+
<button
109+
className='flex w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground'
110+
onClick={handleCreateFolder}
111+
>
112+
<Folder className='h-4 w-4' />
135113
New Folder
136-
</DropdownMenuItem>
137-
</DropdownMenuContent>
138-
</DropdownMenu>
114+
</button>
115+
</PopoverContent>
116+
</Popover>
139117

140118
{/* Folder creation dialog */}
141119
<Dialog open={showFolderDialog} onOpenChange={setShowFolderDialog}>
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
'use client'
2+
3+
import { useCallback, useEffect, useRef } from 'react'
4+
import clsx from 'clsx'
5+
import { ChevronDown, ChevronRight, Folder, FolderOpen } from 'lucide-react'
6+
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
7+
import { type FolderTreeNode, useFolderStore } from '@/stores/folders/store'
8+
import { FolderContextMenu } from '../../folder-context-menu/folder-context-menu'
9+
10+
interface FolderItemProps {
11+
folder: FolderTreeNode
12+
isCollapsed?: boolean
13+
onCreateWorkflow: (folderId?: string) => void
14+
dragOver?: boolean
15+
onDragOver?: (e: React.DragEvent) => void
16+
onDragLeave?: (e: React.DragEvent) => void
17+
onDrop?: (e: React.DragEvent) => void
18+
}
19+
20+
export function FolderItem({
21+
folder,
22+
isCollapsed,
23+
onCreateWorkflow,
24+
dragOver = false,
25+
onDragOver,
26+
onDragLeave,
27+
onDrop,
28+
}: FolderItemProps) {
29+
const { expandedFolders, toggleExpanded, updateFolderAPI, deleteFolder } = useFolderStore()
30+
31+
const isExpanded = expandedFolders.has(folder.id)
32+
const updateTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
33+
const pendingStateRef = useRef<boolean | null>(null)
34+
35+
const handleToggleExpanded = useCallback(() => {
36+
const newExpandedState = !isExpanded
37+
toggleExpanded(folder.id)
38+
pendingStateRef.current = newExpandedState
39+
40+
if (updateTimeoutRef.current) {
41+
clearTimeout(updateTimeoutRef.current)
42+
}
43+
44+
updateTimeoutRef.current = setTimeout(() => {
45+
if (pendingStateRef.current === newExpandedState) {
46+
updateFolderAPI(folder.id, { isExpanded: newExpandedState })
47+
.catch(console.error)
48+
.finally(() => {
49+
pendingStateRef.current = null
50+
})
51+
}
52+
}, 300)
53+
}, [folder.id, isExpanded, toggleExpanded, updateFolderAPI])
54+
55+
useEffect(() => {
56+
return () => {
57+
if (updateTimeoutRef.current) {
58+
clearTimeout(updateTimeoutRef.current)
59+
}
60+
}
61+
}, [])
62+
63+
const handleRename = async (folderId: string, newName: string) => {
64+
try {
65+
await updateFolderAPI(folderId, { name: newName })
66+
} catch (error) {
67+
console.error('Failed to rename folder:', error)
68+
}
69+
}
70+
71+
const handleDelete = async (folderId: string) => {
72+
if (
73+
confirm(
74+
`Are you sure you want to delete "${folder.name}"? Child folders and workflows will be moved to the parent folder.`
75+
)
76+
) {
77+
try {
78+
await deleteFolder(folderId)
79+
} catch (error) {
80+
console.error('Failed to delete folder:', error)
81+
}
82+
}
83+
}
84+
85+
if (isCollapsed) {
86+
return (
87+
<Tooltip>
88+
<TooltipTrigger asChild>
89+
<div
90+
className='group mx-auto flex h-8 w-8 cursor-pointer items-center justify-center'
91+
onDragOver={onDragOver}
92+
onDragLeave={onDragLeave}
93+
onDrop={onDrop}
94+
onClick={handleToggleExpanded}
95+
>
96+
<div
97+
className={clsx(
98+
'flex h-4 w-4 items-center justify-center rounded transition-colors hover:bg-accent/50',
99+
dragOver ? 'ring-2 ring-blue-500' : ''
100+
)}
101+
>
102+
{isExpanded ? (
103+
<FolderOpen className='h-3 w-3 text-foreground/70 dark:text-foreground/60' />
104+
) : (
105+
<Folder className='h-3 w-3 text-foreground/70 dark:text-foreground/60' />
106+
)}
107+
</div>
108+
</div>
109+
</TooltipTrigger>
110+
<TooltipContent side='right'>
111+
<p>{folder.name}</p>
112+
</TooltipContent>
113+
</Tooltip>
114+
)
115+
}
116+
117+
return (
118+
<div className='group' onDragOver={onDragOver} onDragLeave={onDragLeave} onDrop={onDrop}>
119+
<div
120+
className='flex cursor-pointer items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent/50'
121+
onClick={handleToggleExpanded}
122+
>
123+
<div className='mr-1 flex h-4 w-4 items-center justify-center'>
124+
{isExpanded ? <ChevronDown className='h-3 w-3' /> : <ChevronRight className='h-3 w-3' />}
125+
</div>
126+
127+
<div className='mr-2 flex h-4 w-4 flex-shrink-0 items-center justify-center'>
128+
{isExpanded ? (
129+
<FolderOpen className='h-4 w-4 text-foreground/70 dark:text-foreground/60' />
130+
) : (
131+
<Folder className='h-4 w-4 text-foreground/70 dark:text-foreground/60' />
132+
)}
133+
</div>
134+
135+
<span className='flex-1 cursor-default select-none truncate text-muted-foreground'>
136+
{folder.name}
137+
</span>
138+
139+
<div className='flex items-center justify-center' onClick={(e) => e.stopPropagation()}>
140+
<FolderContextMenu
141+
folderId={folder.id}
142+
folderName={folder.name}
143+
onCreateWorkflow={onCreateWorkflow}
144+
onRename={handleRename}
145+
onDelete={handleDelete}
146+
/>
147+
</div>
148+
</div>
149+
</div>
150+
)
151+
}

0 commit comments

Comments
 (0)