Skip to content

Commit e921448

Browse files
committed
fix(selections): more nested folder inaccuracies
1 parent 71d8e22 commit e921448

File tree

3 files changed

+201
-45
lines changed

3 files changed

+201
-45
lines changed

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/workflow-list.tsx

Lines changed: 114 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ export function WorkflowList({
8686
const workflowId = params.workflowId as string
8787

8888
const { isLoading: foldersLoading } = useFolders(workspaceId)
89+
const folders = useFolderStore((state) => state.folders)
8990
const { getFolderTree, expandedFolders, getFolderPath, setExpanded } = useFolderStore()
9091

9192
const {
@@ -119,7 +120,10 @@ export function WorkflowList({
119120
}
120121
}, [scrollContainerRef, setScrollContainer])
121122

122-
const folderTree = workspaceId ? getFolderTree(workspaceId) : []
123+
const folderTree = useMemo(
124+
() => (workspaceId ? getFolderTree(workspaceId) : []),
125+
[workspaceId, folders, getFolderTree]
126+
)
123127

124128
const activeWorkflowFolderId = useMemo(() => {
125129
if (!workflowId || isLoading || foldersLoading) return null
@@ -225,49 +229,135 @@ export function WorkflowList({
225229
const orderedFolderIds = useMemo(() => {
226230
const ids: string[] = []
227231

228-
const collectFolderIds = (folder: FolderTreeNode) => {
232+
const collectFromFolder = (folder: FolderTreeNode) => {
229233
ids.push(folder.id)
230-
for (const childFolder of folder.children) {
231-
collectFolderIds(childFolder)
234+
const workflowsInFolder = workflowsByFolder[folder.id] || []
235+
const childItems: Array<{
236+
type: 'folder' | 'workflow'
237+
id: string
238+
sortOrder: number
239+
createdAt?: Date
240+
data: FolderTreeNode | WorkflowMetadata
241+
}> = []
242+
for (const child of folder.children) {
243+
childItems.push({
244+
type: 'folder',
245+
id: child.id,
246+
sortOrder: child.sortOrder,
247+
createdAt: child.createdAt,
248+
data: child,
249+
})
250+
}
251+
for (const wf of workflowsInFolder) {
252+
childItems.push({
253+
type: 'workflow',
254+
id: wf.id,
255+
sortOrder: wf.sortOrder,
256+
createdAt: wf.createdAt,
257+
data: wf,
258+
})
259+
}
260+
childItems.sort(compareByOrder)
261+
for (const item of childItems) {
262+
if (item.type === 'folder') {
263+
collectFromFolder(item.data as FolderTreeNode)
264+
}
232265
}
233266
}
234267

268+
const rootLevelItems: Array<{
269+
type: 'folder' | 'workflow'
270+
id: string
271+
sortOrder: number
272+
createdAt?: Date
273+
data: FolderTreeNode | WorkflowMetadata
274+
}> = []
235275
for (const folder of folderTree) {
236-
collectFolderIds(folder)
276+
rootLevelItems.push({
277+
type: 'folder',
278+
id: folder.id,
279+
sortOrder: folder.sortOrder,
280+
createdAt: folder.createdAt,
281+
data: folder,
282+
})
283+
}
284+
const rootWfs = workflowsByFolder.root || []
285+
for (const wf of rootWfs) {
286+
rootLevelItems.push({
287+
type: 'workflow',
288+
id: wf.id,
289+
sortOrder: wf.sortOrder,
290+
createdAt: wf.createdAt,
291+
data: wf,
292+
})
293+
}
294+
rootLevelItems.sort(compareByOrder)
295+
296+
for (const item of rootLevelItems) {
297+
if (item.type === 'folder') {
298+
collectFromFolder(item.data as FolderTreeNode)
299+
}
237300
}
238301

239302
return ids
240-
}, [folderTree])
303+
}, [folderTree, workflowsByFolder])
241304

242-
const workflowFolderMap = useMemo(() => {
243-
const map: Record<string, string> = {}
244-
for (const wf of regularWorkflows) {
245-
if (wf.folderId) {
246-
map[wf.id] = wf.folderId
305+
const {
306+
workflowAncestorFolderIds,
307+
folderDescendantWorkflowIds,
308+
folderAncestorIds,
309+
folderDescendantIds,
310+
} = useMemo(() => {
311+
const wfAncestors: Record<string, string[]> = {}
312+
const fDescWfs: Record<string, string[]> = {}
313+
const fAncestors: Record<string, string[]> = {}
314+
const fDescendants: Record<string, string[]> = {}
315+
316+
const buildMaps = (folder: FolderTreeNode, ancestors: string[]) => {
317+
fAncestors[folder.id] = ancestors
318+
const wfsInFolder = (workflowsByFolder[folder.id] || []).map((w) => w.id)
319+
const allDescWfs = [...wfsInFolder]
320+
const allDescFolders: string[] = []
321+
322+
for (const child of folder.children) {
323+
buildMaps(child, [...ancestors, folder.id])
324+
allDescFolders.push(child.id, ...(fDescendants[child.id] || []))
325+
allDescWfs.push(...(fDescWfs[child.id] || []))
247326
}
327+
328+
fDescendants[folder.id] = allDescFolders
329+
fDescWfs[folder.id] = allDescWfs
248330
}
249-
return map
250-
}, [regularWorkflows])
251331

252-
const folderWorkflowIds = useMemo(() => {
253-
const map: Record<string, string[]> = {}
254-
for (const [folderId, workflows] of Object.entries(workflowsByFolder)) {
255-
if (folderId !== 'root') {
256-
map[folderId] = workflows.map((w) => w.id)
332+
for (const folder of folderTree) {
333+
buildMaps(folder, [])
334+
}
335+
336+
for (const wf of regularWorkflows) {
337+
if (wf.folderId && fAncestors[wf.folderId] !== undefined) {
338+
wfAncestors[wf.id] = [wf.folderId, ...fAncestors[wf.folderId]]
257339
}
258340
}
259-
return map
260-
}, [workflowsByFolder])
341+
342+
return {
343+
workflowAncestorFolderIds: wfAncestors,
344+
folderDescendantWorkflowIds: fDescWfs,
345+
folderAncestorIds: fAncestors,
346+
folderDescendantIds: fDescendants,
347+
}
348+
}, [folderTree, workflowsByFolder, regularWorkflows])
261349

262350
const { handleWorkflowClick } = useWorkflowSelection({
263351
workflowIds: orderedWorkflowIds,
264352
activeWorkflowId: workflowId,
265-
workflowFolderMap,
353+
workflowAncestorFolderIds,
266354
})
267355

268356
const { handleFolderClick } = useFolderSelection({
269357
folderIds: orderedFolderIds,
270-
folderWorkflowIds,
358+
folderDescendantWorkflowIds,
359+
folderAncestorIds,
360+
folderDescendantIds,
271361
})
272362

273363
const isWorkflowActive = useCallback(
@@ -472,9 +562,8 @@ export function WorkflowList({
472562
const handleContainerClick = useCallback(
473563
(e: React.MouseEvent<HTMLDivElement>) => {
474564
if (e.target !== e.currentTarget) return
475-
const { selectOnly, clearSelection, clearFolderSelection } = useFolderStore.getState()
476-
clearFolderSelection()
477-
workflowId ? selectOnly(workflowId) : clearSelection()
565+
const { selectOnly, clearAllSelection } = useFolderStore.getState()
566+
workflowId ? selectOnly(workflowId) : clearAllSelection()
478567
},
479568
[workflowId]
480569
)

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

Lines changed: 74 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,37 @@ interface UseFolderSelectionProps {
77
*/
88
folderIds: string[]
99
/**
10-
* Map from folder ID to the workflow IDs directly inside that folder
10+
* Map from folder ID to ALL descendant workflow IDs (recursively, not just direct children)
1111
*/
12-
folderWorkflowIds: Record<string, string[]>
12+
folderDescendantWorkflowIds: Record<string, string[]>
13+
/**
14+
* Map from folder ID to all its ancestor folder IDs
15+
*/
16+
folderAncestorIds: Record<string, string[]>
17+
/**
18+
* Map from folder ID to all its descendant folder IDs
19+
*/
20+
folderDescendantIds: Record<string, string[]>
1321
}
1422

1523
/**
1624
* Hook for managing folder selection with support for single, range, and toggle selection.
1725
* Handles shift-click for range selection and cmd/ctrl-click for toggle selection.
1826
* Uses the last selected folder ID (tracked in store) as the anchor point for range selections.
19-
* Enforces parent-child constraint: selecting a folder deselects workflows inside it.
27+
* Enforces three constraints:
28+
* - Selecting a folder deselects any workflows in its entire subtree
29+
* - Cmd+click on a folder deselects its ancestors and descendants (clicked folder wins)
30+
* - Range selection deduplicates ancestor-descendant pairs (keeps the ancestor)
2031
*
2132
* @param props - Hook props
2233
* @returns Selection handlers
2334
*/
24-
export function useFolderSelection({ folderIds, folderWorkflowIds }: UseFolderSelectionProps) {
35+
export function useFolderSelection({
36+
folderIds,
37+
folderDescendantWorkflowIds,
38+
folderAncestorIds,
39+
folderDescendantIds,
40+
}: UseFolderSelectionProps) {
2541
const {
2642
selectedFolders,
2743
lastSelectedFolderId,
@@ -31,23 +47,67 @@ export function useFolderSelection({ folderIds, folderWorkflowIds }: UseFolderSe
3147
} = useFolderStore()
3248

3349
/**
34-
* After a folder selection change, deselect any workflows whose parent folder is selected
35-
* to prevent parent-child co-selection.
50+
* Deselect any workflows whose folder (or any ancestor folder) is currently selected.
3651
*/
3752
const deselectConflictingWorkflows = useCallback(() => {
3853
const { selectedWorkflows: workflows, selectedFolders: folders } = useFolderStore.getState()
3954
if (workflows.size === 0) return
4055

4156
for (const folderId of folders) {
42-
const wfIdsInFolder = folderWorkflowIds[folderId]
43-
if (!wfIdsInFolder) continue
44-
for (const wfId of wfIdsInFolder) {
57+
const wfIds = folderDescendantWorkflowIds[folderId]
58+
if (!wfIds) continue
59+
for (const wfId of wfIds) {
4560
if (workflows.has(wfId)) {
4661
useFolderStore.getState().deselectWorkflow(wfId)
4762
}
4863
}
4964
}
50-
}, [folderWorkflowIds])
65+
}, [folderDescendantWorkflowIds])
66+
67+
/**
68+
* For Cmd+click: the clicked folder wins. Deselect any selected folders that are
69+
* ancestors or descendants of the clicked folder.
70+
*/
71+
const deselectRelatedFolders = useCallback(
72+
(clickedFolderId: string) => {
73+
const { selectedFolders: folders } = useFolderStore.getState()
74+
if (!folders.has(clickedFolderId) || folders.size <= 1) return
75+
76+
const ancestors = folderAncestorIds[clickedFolderId] || []
77+
const descendants = folderDescendantIds[clickedFolderId] || []
78+
79+
for (const id of ancestors) {
80+
if (folders.has(id)) {
81+
useFolderStore.getState().deselectFolder(id)
82+
}
83+
}
84+
for (const id of descendants) {
85+
if (folders.has(id)) {
86+
useFolderStore.getState().deselectFolder(id)
87+
}
88+
}
89+
},
90+
[folderAncestorIds, folderDescendantIds]
91+
)
92+
93+
/**
94+
* For range selection: if both a folder and a nested subfolder end up in the range,
95+
* keep the ancestor and deselect the descendant (ancestor already covers it).
96+
*/
97+
const deduplicateSelectedFolders = useCallback(() => {
98+
const { selectedFolders: folders } = useFolderStore.getState()
99+
if (folders.size <= 1) return
100+
101+
for (const folderId of folders) {
102+
const ancestors = folderAncestorIds[folderId] || []
103+
for (const ancestorId of ancestors) {
104+
if (folders.has(ancestorId)) {
105+
useFolderStore.getState().deselectFolder(folderId)
106+
break
107+
}
108+
}
109+
}
110+
}, [folderAncestorIds])
51111

52112
/**
53113
* Handle folder click with support for shift-click range selection and cmd/ctrl-click toggle
@@ -60,9 +120,11 @@ export function useFolderSelection({ folderIds, folderWorkflowIds }: UseFolderSe
60120
(folderId: string, shiftKey: boolean, metaKey: boolean) => {
61121
if (metaKey) {
62122
toggleFolderSelection(folderId)
123+
deselectRelatedFolders(folderId)
63124
deselectConflictingWorkflows()
64125
} else if (shiftKey && lastSelectedFolderId && lastSelectedFolderId !== folderId) {
65126
selectFolderRange(folderIds, lastSelectedFolderId, folderId)
127+
deduplicateSelectedFolders()
66128
deselectConflictingWorkflows()
67129
} else if (shiftKey) {
68130
selectFolderOnly(folderId)
@@ -78,6 +140,8 @@ export function useFolderSelection({ folderIds, folderWorkflowIds }: UseFolderSe
78140
selectFolderOnly,
79141
selectFolderRange,
80142
toggleFolderSelection,
143+
deselectRelatedFolders,
144+
deduplicateSelectedFolders,
81145
deselectConflictingWorkflows,
82146
]
83147
)

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

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,42 +11,45 @@ interface UseWorkflowSelectionProps {
1111
*/
1212
activeWorkflowId: string | undefined
1313
/**
14-
* Map from workflow ID to its parent folder ID (only for workflows inside folders)
14+
* Map from workflow ID to all its ancestor folder IDs (direct parent first, then up)
1515
*/
16-
workflowFolderMap: Record<string, string>
16+
workflowAncestorFolderIds: Record<string, string[]>
1717
}
1818

1919
/**
2020
* Hook for managing workflow selection with support for single, range, and toggle selection.
2121
* Handles shift-click for range selection and regular click for single selection.
2222
* Uses the active workflow ID as the anchor point for range selections.
23-
* Enforces parent-child constraint: selecting a workflow deselects its parent folder.
23+
* Enforces ancestor constraint: selecting a workflow deselects any ancestor folder.
2424
*
2525
* @param props - Hook props
2626
* @returns Selection handlers
2727
*/
2828
export function useWorkflowSelection({
2929
workflowIds,
3030
activeWorkflowId,
31-
workflowFolderMap,
31+
workflowAncestorFolderIds,
3232
}: UseWorkflowSelectionProps) {
3333
const { selectedWorkflows, selectOnly, selectRange, toggleWorkflowSelection } = useFolderStore()
3434

3535
/**
36-
* After a workflow selection change, deselect any folders that contain selected workflows
37-
* to prevent parent-child co-selection.
36+
* After a workflow selection change, deselect any folder that is an ancestor of a selected
37+
* workflow to prevent ancestor-descendant co-selection.
3838
*/
3939
const deselectConflictingFolders = useCallback(() => {
4040
const { selectedWorkflows: workflows, selectedFolders: folders } = useFolderStore.getState()
4141
if (folders.size === 0) return
4242

4343
for (const wfId of workflows) {
44-
const folderId = workflowFolderMap[wfId]
45-
if (folderId && folders.has(folderId)) {
46-
useFolderStore.getState().deselectFolder(folderId)
44+
const ancestorIds = workflowAncestorFolderIds[wfId]
45+
if (!ancestorIds) continue
46+
for (const folderId of ancestorIds) {
47+
if (folders.has(folderId)) {
48+
useFolderStore.getState().deselectFolder(folderId)
49+
}
4750
}
4851
}
49-
}, [workflowFolderMap])
52+
}, [workflowAncestorFolderIds])
5053

5154
/**
5255
* Handle workflow click with support for shift-click range selection and cmd/ctrl-click toggle.

0 commit comments

Comments
 (0)