Skip to content

Commit 351873a

Browse files
authored
improvement(sidebar): interleave folders and workflows by sort order in all resource pickers (#4215)
* improvement(sidebar): interleave folders and workflows by sort order in all resource pickers - Merge folder/workflow submenus into a single Workflows tree sorted by sortOrder in both the @ plus-menu and add-resource dropdowns - Widen both dropdowns from 240px to 320px and remove type labels from search results - Fix isOpen/onSwitch regression: WorkflowFolderTreeItems now forwards node.isOpen so already-open tabs are switched to rather than duplicated - Apply same interleaved sortOrder ordering to the collapsed sidebar's root-level folder+workflow list * fix(add-resource-dropdown): align sort tiebreaker with compareByOrder, document empty-folder omission Use id.localeCompare as the sort tiebreaker in buildWorkflowFolderTree to match the sidebar's compareByOrder fallback (sortOrder → id) instead of name. Add a comment clarifying that empty folders are intentionally omitted from the tree view. * chore: remove extraneous inline comment
1 parent 38864fa commit 351873a

File tree

4 files changed

+340
-186
lines changed

4 files changed

+340
-186
lines changed

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx

Lines changed: 157 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { useCallback, useMemo, useState } from 'react'
3+
import { useMemo, useState } from 'react'
44
import {
55
Button,
66
DropdownMenu,
@@ -13,7 +13,7 @@ import {
1313
DropdownMenuTrigger,
1414
Tooltip,
1515
} from '@/components/emcn'
16-
import { Plus } from '@/components/emcn/icons'
16+
import { Folder, Plus } from '@/components/emcn/icons'
1717
import { cn } from '@/lib/core/utils/cn'
1818
import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
1919
import {
@@ -68,6 +68,8 @@ export function useAvailableResources(
6868
id: w.id,
6969
name: w.name,
7070
color: w.color,
71+
folderId: w.folderId ?? null,
72+
sortOrder: w.sortOrder,
7173
isOpen: existingKeys.has(`workflow:${w.id}`),
7274
})),
7375
},
@@ -76,6 +78,8 @@ export function useAvailableResources(
7678
items: folders.map((f) => ({
7779
id: f.id,
7880
name: f.name,
81+
parentId: f.parentId ?? null,
82+
sortOrder: f.sortOrder,
7983
isOpen: existingKeys.has(`folder:${f.id}`),
8084
})),
8185
},
@@ -116,6 +120,104 @@ export function useAvailableResources(
116120
}, [workflows, folders, tables, files, knowledgeBases, tasks, existingKeys, excludeTypes])
117121
}
118122

123+
export type WorkflowTreeNode =
124+
| { kind: 'workflow'; id: string; name: string; color: string; isOpen?: boolean }
125+
| { kind: 'folder'; id: string; name: string; children: WorkflowTreeNode[] }
126+
127+
export function buildWorkflowFolderTree(
128+
workflowItems: AvailableItem[],
129+
folderItems: AvailableItem[]
130+
): WorkflowTreeNode[] {
131+
const knownFolderIds = new Set(folderItems.map((f) => f.id))
132+
133+
const byFolder = new Map<string | null, AvailableItem[]>()
134+
for (const w of workflowItems) {
135+
const fid = (w.folderId as string | null | undefined) ?? null
136+
const key = fid && knownFolderIds.has(fid) ? fid : null
137+
const bucket = byFolder.get(key) ?? []
138+
bucket.push(w)
139+
byFolder.set(key, bucket)
140+
}
141+
142+
const toWorkflowNode = (w: AvailableItem): WorkflowTreeNode => ({
143+
kind: 'workflow',
144+
id: w.id,
145+
name: w.name,
146+
color: (w.color as string) ?? '#808080',
147+
isOpen: w.isOpen,
148+
})
149+
150+
const buildLevel = (parentId: string | null): WorkflowTreeNode[] => {
151+
const childFolders = folderItems.filter(
152+
(f) => ((f.parentId as string | null | undefined) ?? null) === parentId
153+
)
154+
const childWorkflows = byFolder.get(parentId) ?? []
155+
156+
const mixed: Array<{ sortOrder: number; id: string; node: WorkflowTreeNode }> = []
157+
158+
for (const f of childFolders) {
159+
const children = buildLevel(f.id)
160+
if (children.length === 0) continue
161+
mixed.push({
162+
sortOrder: (f.sortOrder as number) ?? 0,
163+
id: f.id,
164+
node: { kind: 'folder', id: f.id, name: f.name, children },
165+
})
166+
}
167+
168+
for (const w of childWorkflows) {
169+
mixed.push({
170+
sortOrder: (w.sortOrder as number) ?? 0,
171+
id: w.id,
172+
node: toWorkflowNode(w),
173+
})
174+
}
175+
176+
mixed.sort((a, b) =>
177+
a.sortOrder !== b.sortOrder ? a.sortOrder - b.sortOrder : a.id.localeCompare(b.id)
178+
)
179+
return mixed.map((m) => m.node)
180+
}
181+
182+
return buildLevel(null)
183+
}
184+
185+
interface WorkflowFolderTreeItemsProps {
186+
nodes: WorkflowTreeNode[]
187+
onSelect: (resource: MothershipResource, isOpen?: boolean) => void
188+
}
189+
190+
export function WorkflowFolderTreeItems({ nodes, onSelect }: WorkflowFolderTreeItemsProps) {
191+
return (
192+
<>
193+
{nodes.map((node) =>
194+
node.kind === 'workflow' ? (
195+
<DropdownMenuItem
196+
key={node.id}
197+
onClick={() =>
198+
onSelect({ type: 'workflow', id: node.id, title: node.name }, node.isOpen)
199+
}
200+
>
201+
{getResourceConfig('workflow').renderDropdownItem({
202+
item: { id: node.id, name: node.name, color: node.color },
203+
})}
204+
</DropdownMenuItem>
205+
) : (
206+
<DropdownMenuSub key={node.id}>
207+
<DropdownMenuSubTrigger>
208+
<Folder className='h-[14px] w-[14px]' />
209+
<span>{node.name}</span>
210+
</DropdownMenuSubTrigger>
211+
<DropdownMenuSubContent>
212+
<WorkflowFolderTreeItems nodes={node.children} onSelect={onSelect} />
213+
</DropdownMenuSubContent>
214+
</DropdownMenuSub>
215+
)
216+
)}
217+
</>
218+
)
219+
}
220+
119221
export function AddResourceDropdown({
120222
workspaceId,
121223
existingKeys,
@@ -128,27 +230,30 @@ export function AddResourceDropdown({
128230
const [activeIndex, setActiveIndex] = useState(0)
129231
const available = useAvailableResources(workspaceId, existingKeys, excludeTypes)
130232

131-
const handleOpenChange = useCallback((next: boolean) => {
233+
const handleOpenChange = (next: boolean) => {
132234
setOpen(next)
133235
if (!next) {
134236
setSearch('')
135237
setActiveIndex(0)
136238
}
137-
}, [])
239+
}
138240

139-
const select = useCallback(
140-
(resource: MothershipResource, isOpen?: boolean) => {
141-
if (isOpen && onSwitch) {
142-
onSwitch(resource.id)
143-
} else {
144-
onAdd(resource)
145-
}
146-
setOpen(false)
147-
setSearch('')
148-
setActiveIndex(0)
149-
},
150-
[onAdd, onSwitch]
151-
)
241+
const select = (resource: MothershipResource, isOpen?: boolean) => {
242+
if (isOpen && onSwitch) {
243+
onSwitch(resource.id)
244+
} else {
245+
onAdd(resource)
246+
}
247+
setOpen(false)
248+
setSearch('')
249+
setActiveIndex(0)
250+
}
251+
252+
const workflowTree = useMemo(() => {
253+
const workflowGroup = available.find((g) => g.type === 'workflow')
254+
const folderGroup = available.find((g) => g.type === 'folder')
255+
return buildWorkflowFolderTree(workflowGroup?.items ?? [], folderGroup?.items ?? [])
256+
}, [available])
152257

153258
const filtered = useMemo(() => {
154259
const q = search.toLowerCase().trim()
@@ -158,25 +263,22 @@ export function AddResourceDropdown({
158263
)
159264
}, [search, available])
160265

161-
const handleSearchKeyDown = useCallback(
162-
(e: React.KeyboardEvent<HTMLInputElement>) => {
163-
if (!filtered) return
164-
if (e.key === 'ArrowDown') {
266+
const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
267+
if (!filtered) return
268+
if (e.key === 'ArrowDown') {
269+
e.preventDefault()
270+
setActiveIndex((prev) => Math.min(prev + 1, filtered.length - 1))
271+
} else if (e.key === 'ArrowUp') {
272+
e.preventDefault()
273+
setActiveIndex((prev) => Math.max(prev - 1, 0))
274+
} else if (e.key === 'Enter' || (e.key === 'Tab' && !e.shiftKey)) {
275+
if (filtered.length > 0 && filtered[activeIndex]) {
165276
e.preventDefault()
166-
setActiveIndex((prev) => Math.min(prev + 1, filtered.length - 1))
167-
} else if (e.key === 'ArrowUp') {
168-
e.preventDefault()
169-
setActiveIndex((prev) => Math.max(prev - 1, 0))
170-
} else if (e.key === 'Enter' || (e.key === 'Tab' && !e.shiftKey)) {
171-
if (filtered.length > 0 && filtered[activeIndex]) {
172-
e.preventDefault()
173-
const { type, item } = filtered[activeIndex]
174-
select({ type, id: item.id, title: item.name }, item.isOpen)
175-
}
277+
const { type, item } = filtered[activeIndex]
278+
select({ type, id: item.id, title: item.name }, item.isOpen)
176279
}
177-
},
178-
[filtered, activeIndex, select]
179-
)
280+
}
281+
}
180282

181283
return (
182284
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
@@ -199,7 +301,7 @@ export function AddResourceDropdown({
199301
<DropdownMenuContent
200302
align='start'
201303
sideOffset={8}
202-
className='flex w-[240px] flex-col overflow-hidden'
304+
className='flex w-[320px] flex-col overflow-hidden'
203305
onCloseAutoFocus={(e) => e.preventDefault()}
204306
>
205307
<DropdownMenuSearchInput
@@ -224,9 +326,6 @@ export function AddResourceDropdown({
224326
onClick={() => select({ type, id: item.id, title: item.name }, item.isOpen)}
225327
>
226328
{config.renderDropdownItem({ item })}
227-
<span className='ml-auto pl-2 text-[var(--text-tertiary)] text-xs'>
228-
{config.label}
229-
</span>
230329
</DropdownMenuItem>
231330
)
232331
})
@@ -237,25 +336,33 @@ export function AddResourceDropdown({
237336
)
238337
) : (
239338
<>
339+
{workflowTree.length > 0 && (
340+
<DropdownMenuSub>
341+
<DropdownMenuSubTrigger>
342+
<div
343+
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
344+
style={{
345+
backgroundColor: '#808080',
346+
borderColor: '#80808060',
347+
backgroundClip: 'padding-box',
348+
}}
349+
/>
350+
<span>Workflows</span>
351+
</DropdownMenuSubTrigger>
352+
<DropdownMenuSubContent>
353+
<WorkflowFolderTreeItems nodes={workflowTree} onSelect={select} />
354+
</DropdownMenuSubContent>
355+
</DropdownMenuSub>
356+
)}
240357
{available.map(({ type, items }) => {
358+
if (type === 'workflow' || type === 'folder') return null
241359
if (items.length === 0) return null
242360
const config = getResourceConfig(type)
243361
const Icon = config.icon
244362
return (
245363
<DropdownMenuSub key={type}>
246364
<DropdownMenuSubTrigger>
247-
{type === 'workflow' ? (
248-
<div
249-
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
250-
style={{
251-
backgroundColor: '#808080',
252-
borderColor: '#80808060',
253-
backgroundClip: 'padding-box',
254-
}}
255-
/>
256-
) : (
257-
<Icon className='h-[14px] w-[14px]' />
258-
)}
365+
<Icon className='h-[14px] w-[14px]' />
259366
<span>{config.label}</span>
260367
</DropdownMenuSubTrigger>
261368
<DropdownMenuSubContent>
Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,11 @@
1-
export type { AddResourceDropdownProps, AvailableItem } from './add-resource-dropdown'
2-
export { AddResourceDropdown, useAvailableResources } from './add-resource-dropdown'
1+
export type {
2+
AddResourceDropdownProps,
3+
AvailableItem,
4+
WorkflowTreeNode,
5+
} from './add-resource-dropdown'
6+
export {
7+
AddResourceDropdown,
8+
buildWorkflowFolderTree,
9+
useAvailableResources,
10+
WorkflowFolderTreeItems,
11+
} from './add-resource-dropdown'

0 commit comments

Comments
 (0)