Skip to content

Commit 71d8e22

Browse files
committed
improvement(folder-selection): folder deselection + selection order should match visual
1 parent 4593a8a commit 71d8e22

File tree

4 files changed

+171
-35
lines changed

4 files changed

+171
-35
lines changed

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

Lines changed: 85 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -146,23 +146,77 @@ export function WorkflowList({
146146
const orderedWorkflowIds = useMemo(() => {
147147
const ids: string[] = []
148148

149-
const collectWorkflowIds = (folder: FolderTreeNode) => {
149+
const collectFromFolder = (folder: FolderTreeNode) => {
150150
const workflowsInFolder = workflowsByFolder[folder.id] || []
151-
for (const workflow of workflowsInFolder) {
152-
ids.push(workflow.id)
151+
const childItems: Array<{
152+
type: 'folder' | 'workflow'
153+
id: string
154+
sortOrder: number
155+
createdAt?: Date
156+
data: FolderTreeNode | WorkflowMetadata
157+
}> = []
158+
for (const child of folder.children) {
159+
childItems.push({
160+
type: 'folder',
161+
id: child.id,
162+
sortOrder: child.sortOrder,
163+
createdAt: child.createdAt,
164+
data: child,
165+
})
153166
}
154-
for (const childFolder of folder.children) {
155-
collectWorkflowIds(childFolder)
167+
for (const wf of workflowsInFolder) {
168+
childItems.push({
169+
type: 'workflow',
170+
id: wf.id,
171+
sortOrder: wf.sortOrder,
172+
createdAt: wf.createdAt,
173+
data: wf,
174+
})
175+
}
176+
childItems.sort(compareByOrder)
177+
for (const item of childItems) {
178+
if (item.type === 'workflow') {
179+
ids.push(item.id)
180+
} else {
181+
collectFromFolder(item.data as FolderTreeNode)
182+
}
156183
}
157184
}
158185

186+
const rootLevelItems: Array<{
187+
type: 'folder' | 'workflow'
188+
id: string
189+
sortOrder: number
190+
createdAt?: Date
191+
data: FolderTreeNode | WorkflowMetadata
192+
}> = []
159193
for (const folder of folderTree) {
160-
collectWorkflowIds(folder)
194+
rootLevelItems.push({
195+
type: 'folder',
196+
id: folder.id,
197+
sortOrder: folder.sortOrder,
198+
createdAt: folder.createdAt,
199+
data: folder,
200+
})
201+
}
202+
const rootWfs = workflowsByFolder.root || []
203+
for (const wf of rootWfs) {
204+
rootLevelItems.push({
205+
type: 'workflow',
206+
id: wf.id,
207+
sortOrder: wf.sortOrder,
208+
createdAt: wf.createdAt,
209+
data: wf,
210+
})
161211
}
212+
rootLevelItems.sort(compareByOrder)
162213

163-
const rootWorkflows = workflowsByFolder.root || []
164-
for (const workflow of rootWorkflows) {
165-
ids.push(workflow.id)
214+
for (const item of rootLevelItems) {
215+
if (item.type === 'workflow') {
216+
ids.push(item.id)
217+
} else {
218+
collectFromFolder(item.data as FolderTreeNode)
219+
}
166220
}
167221

168222
return ids
@@ -185,13 +239,35 @@ export function WorkflowList({
185239
return ids
186240
}, [folderTree])
187241

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
247+
}
248+
}
249+
return map
250+
}, [regularWorkflows])
251+
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)
257+
}
258+
}
259+
return map
260+
}, [workflowsByFolder])
261+
188262
const { handleWorkflowClick } = useWorkflowSelection({
189263
workflowIds: orderedWorkflowIds,
190264
activeWorkflowId: workflowId,
265+
workflowFolderMap,
191266
})
192267

193268
const { handleFolderClick } = useFolderSelection({
194269
folderIds: orderedFolderIds,
270+
folderWorkflowIds,
195271
})
196272

197273
const isWorkflowActive = useCallback(

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

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,22 @@ interface UseFolderSelectionProps {
66
* Flat array of all folder IDs in display order
77
*/
88
folderIds: string[]
9+
/**
10+
* Map from folder ID to the workflow IDs directly inside that folder
11+
*/
12+
folderWorkflowIds: Record<string, string[]>
913
}
1014

1115
/**
1216
* Hook for managing folder selection with support for single, range, and toggle selection.
1317
* Handles shift-click for range selection and cmd/ctrl-click for toggle selection.
1418
* 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.
1520
*
1621
* @param props - Hook props
1722
* @returns Selection handlers
1823
*/
19-
export function useFolderSelection({ folderIds }: UseFolderSelectionProps) {
24+
export function useFolderSelection({ folderIds, folderWorkflowIds }: UseFolderSelectionProps) {
2025
const {
2126
selectedFolders,
2227
lastSelectedFolderId,
@@ -25,6 +30,25 @@ export function useFolderSelection({ folderIds }: UseFolderSelectionProps) {
2530
toggleFolderSelection,
2631
} = useFolderStore()
2732

33+
/**
34+
* After a folder selection change, deselect any workflows whose parent folder is selected
35+
* to prevent parent-child co-selection.
36+
*/
37+
const deselectConflictingWorkflows = useCallback(() => {
38+
const { selectedWorkflows: workflows, selectedFolders: folders } = useFolderStore.getState()
39+
if (workflows.size === 0) return
40+
41+
for (const folderId of folders) {
42+
const wfIdsInFolder = folderWorkflowIds[folderId]
43+
if (!wfIdsInFolder) continue
44+
for (const wfId of wfIdsInFolder) {
45+
if (workflows.has(wfId)) {
46+
useFolderStore.getState().deselectWorkflow(wfId)
47+
}
48+
}
49+
}
50+
}, [folderWorkflowIds])
51+
2852
/**
2953
* Handle folder click with support for shift-click range selection and cmd/ctrl-click toggle
3054
*
@@ -34,24 +58,28 @@ export function useFolderSelection({ folderIds }: UseFolderSelectionProps) {
3458
*/
3559
const handleFolderClick = useCallback(
3660
(folderId: string, shiftKey: boolean, metaKey: boolean) => {
37-
// Cmd/Ctrl+Click: Toggle individual selection
3861
if (metaKey) {
3962
toggleFolderSelection(folderId)
40-
}
41-
// Shift+Click: Range selection from last selected folder to clicked folder
42-
else if (shiftKey && lastSelectedFolderId && lastSelectedFolderId !== folderId) {
63+
deselectConflictingWorkflows()
64+
} else if (shiftKey && lastSelectedFolderId && lastSelectedFolderId !== folderId) {
4365
selectFolderRange(folderIds, lastSelectedFolderId, folderId)
44-
}
45-
// Shift+Click without anchor: Select only this folder (establishes anchor)
46-
else if (shiftKey) {
66+
deselectConflictingWorkflows()
67+
} else if (shiftKey) {
4768
selectFolderOnly(folderId)
48-
}
49-
// Regular click: Select only this folder
50-
else {
69+
deselectConflictingWorkflows()
70+
} else {
5171
selectFolderOnly(folderId)
72+
deselectConflictingWorkflows()
5273
}
5374
},
54-
[folderIds, lastSelectedFolderId, selectFolderOnly, selectFolderRange, toggleFolderSelection]
75+
[
76+
folderIds,
77+
lastSelectedFolderId,
78+
selectFolderOnly,
79+
selectFolderRange,
80+
toggleFolderSelection,
81+
deselectConflictingWorkflows,
82+
]
5583
)
5684

5785
return {

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

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,47 +10,74 @@ interface UseWorkflowSelectionProps {
1010
* Active workflow ID (from URL) - used as anchor for range selection
1111
*/
1212
activeWorkflowId: string | undefined
13+
/**
14+
* Map from workflow ID to its parent folder ID (only for workflows inside folders)
15+
*/
16+
workflowFolderMap: Record<string, string>
1317
}
1418

1519
/**
1620
* Hook for managing workflow selection with support for single, range, and toggle selection.
1721
* Handles shift-click for range selection and regular click for single selection.
1822
* Uses the active workflow ID as the anchor point for range selections.
23+
* Enforces parent-child constraint: selecting a workflow deselects its parent folder.
1924
*
2025
* @param props - Hook props
2126
* @returns Selection handlers
2227
*/
23-
export function useWorkflowSelection({ workflowIds, activeWorkflowId }: UseWorkflowSelectionProps) {
28+
export function useWorkflowSelection({
29+
workflowIds,
30+
activeWorkflowId,
31+
workflowFolderMap,
32+
}: UseWorkflowSelectionProps) {
2433
const { selectedWorkflows, selectOnly, selectRange, toggleWorkflowSelection } = useFolderStore()
2534

35+
/**
36+
* After a workflow selection change, deselect any folders that contain selected workflows
37+
* to prevent parent-child co-selection.
38+
*/
39+
const deselectConflictingFolders = useCallback(() => {
40+
const { selectedWorkflows: workflows, selectedFolders: folders } = useFolderStore.getState()
41+
if (folders.size === 0) return
42+
43+
for (const wfId of workflows) {
44+
const folderId = workflowFolderMap[wfId]
45+
if (folderId && folders.has(folderId)) {
46+
useFolderStore.getState().deselectFolder(folderId)
47+
}
48+
}
49+
}, [workflowFolderMap])
50+
2651
/**
2752
* Handle workflow click with support for shift-click range selection and cmd/ctrl-click toggle.
28-
* Does not clear folder selection to allow unified selection of workflows and folders.
2953
*
3054
* @param workflowId - ID of clicked workflow
3155
* @param shiftKey - Whether shift key was pressed
3256
* @param metaKey - Whether cmd (Mac) or ctrl (Windows) key was pressed
3357
*/
3458
const handleWorkflowClick = useCallback(
3559
(workflowId: string, shiftKey: boolean, metaKey: boolean) => {
36-
// Cmd/Ctrl+Click: Toggle individual selection
3760
if (metaKey) {
3861
toggleWorkflowSelection(workflowId)
39-
}
40-
// Shift+Click: Range selection from active workflow to clicked workflow
41-
else if (shiftKey && activeWorkflowId && activeWorkflowId !== workflowId) {
62+
deselectConflictingFolders()
63+
} else if (shiftKey && activeWorkflowId && activeWorkflowId !== workflowId) {
4264
selectRange(workflowIds, activeWorkflowId, workflowId)
43-
}
44-
// Shift+Click without active workflow: Toggle selection
45-
else if (shiftKey) {
65+
deselectConflictingFolders()
66+
} else if (shiftKey) {
4667
toggleWorkflowSelection(workflowId)
47-
}
48-
// Regular click: Select only this workflow (preserves folder selection for unified multi-select)
49-
else {
68+
deselectConflictingFolders()
69+
} else {
5070
selectOnly(workflowId)
5171
}
5272
},
53-
[workflowIds, activeWorkflowId, selectOnly, selectRange, toggleWorkflowSelection]
73+
[
74+
workflowIds,
75+
activeWorkflowId,
76+
selectOnly,
77+
selectRange,
78+
toggleWorkflowSelection,
79+
deselectConflictingFolders,
80+
]
5481
)
5582

5683
return {

apps/sim/stores/folders/store.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,12 @@ export const useFolderStore = create<FolderState>()(
117117

118118
clearSelection: () => set({ selectedWorkflows: new Set() }),
119119

120-
selectOnly: (workflowId) => set({ selectedWorkflows: new Set([workflowId]) }),
120+
selectOnly: (workflowId) =>
121+
set({
122+
selectedWorkflows: new Set([workflowId]),
123+
selectedFolders: new Set(),
124+
lastSelectedFolderId: null,
125+
}),
121126

122127
selectRange: (workflowIds, fromId, toId) => {
123128
const fromIndex = workflowIds.indexOf(fromId)

0 commit comments

Comments
 (0)