mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
fix(selections): more nested folder inaccuracies
This commit is contained in:
@@ -86,6 +86,7 @@ export function WorkflowList({
|
||||
const workflowId = params.workflowId as string
|
||||
|
||||
const { isLoading: foldersLoading } = useFolders(workspaceId)
|
||||
const folders = useFolderStore((state) => state.folders)
|
||||
const { getFolderTree, expandedFolders, getFolderPath, setExpanded } = useFolderStore()
|
||||
|
||||
const {
|
||||
@@ -119,7 +120,10 @@ export function WorkflowList({
|
||||
}
|
||||
}, [scrollContainerRef, setScrollContainer])
|
||||
|
||||
const folderTree = workspaceId ? getFolderTree(workspaceId) : []
|
||||
const folderTree = useMemo(
|
||||
() => (workspaceId ? getFolderTree(workspaceId) : []),
|
||||
[workspaceId, folders, getFolderTree]
|
||||
)
|
||||
|
||||
const activeWorkflowFolderId = useMemo(() => {
|
||||
if (!workflowId || isLoading || foldersLoading) return null
|
||||
@@ -225,49 +229,135 @@ export function WorkflowList({
|
||||
const orderedFolderIds = useMemo(() => {
|
||||
const ids: string[] = []
|
||||
|
||||
const collectFolderIds = (folder: FolderTreeNode) => {
|
||||
const collectFromFolder = (folder: FolderTreeNode) => {
|
||||
ids.push(folder.id)
|
||||
for (const childFolder of folder.children) {
|
||||
collectFolderIds(childFolder)
|
||||
const workflowsInFolder = workflowsByFolder[folder.id] || []
|
||||
const childItems: Array<{
|
||||
type: 'folder' | 'workflow'
|
||||
id: string
|
||||
sortOrder: number
|
||||
createdAt?: Date
|
||||
data: FolderTreeNode | WorkflowMetadata
|
||||
}> = []
|
||||
for (const child of folder.children) {
|
||||
childItems.push({
|
||||
type: 'folder',
|
||||
id: child.id,
|
||||
sortOrder: child.sortOrder,
|
||||
createdAt: child.createdAt,
|
||||
data: child,
|
||||
})
|
||||
}
|
||||
for (const wf of workflowsInFolder) {
|
||||
childItems.push({
|
||||
type: 'workflow',
|
||||
id: wf.id,
|
||||
sortOrder: wf.sortOrder,
|
||||
createdAt: wf.createdAt,
|
||||
data: wf,
|
||||
})
|
||||
}
|
||||
childItems.sort(compareByOrder)
|
||||
for (const item of childItems) {
|
||||
if (item.type === 'folder') {
|
||||
collectFromFolder(item.data as FolderTreeNode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rootLevelItems: Array<{
|
||||
type: 'folder' | 'workflow'
|
||||
id: string
|
||||
sortOrder: number
|
||||
createdAt?: Date
|
||||
data: FolderTreeNode | WorkflowMetadata
|
||||
}> = []
|
||||
for (const folder of folderTree) {
|
||||
collectFolderIds(folder)
|
||||
rootLevelItems.push({
|
||||
type: 'folder',
|
||||
id: folder.id,
|
||||
sortOrder: folder.sortOrder,
|
||||
createdAt: folder.createdAt,
|
||||
data: folder,
|
||||
})
|
||||
}
|
||||
const rootWfs = workflowsByFolder.root || []
|
||||
for (const wf of rootWfs) {
|
||||
rootLevelItems.push({
|
||||
type: 'workflow',
|
||||
id: wf.id,
|
||||
sortOrder: wf.sortOrder,
|
||||
createdAt: wf.createdAt,
|
||||
data: wf,
|
||||
})
|
||||
}
|
||||
rootLevelItems.sort(compareByOrder)
|
||||
|
||||
for (const item of rootLevelItems) {
|
||||
if (item.type === 'folder') {
|
||||
collectFromFolder(item.data as FolderTreeNode)
|
||||
}
|
||||
}
|
||||
|
||||
return ids
|
||||
}, [folderTree])
|
||||
}, [folderTree, workflowsByFolder])
|
||||
|
||||
const {
|
||||
workflowAncestorFolderIds,
|
||||
folderDescendantWorkflowIds,
|
||||
folderAncestorIds,
|
||||
folderDescendantIds,
|
||||
} = useMemo(() => {
|
||||
const wfAncestors: Record<string, string[]> = {}
|
||||
const fDescWfs: Record<string, string[]> = {}
|
||||
const fAncestors: Record<string, string[]> = {}
|
||||
const fDescendants: Record<string, string[]> = {}
|
||||
|
||||
const buildMaps = (folder: FolderTreeNode, ancestors: string[]) => {
|
||||
fAncestors[folder.id] = ancestors
|
||||
const wfsInFolder = (workflowsByFolder[folder.id] || []).map((w) => w.id)
|
||||
const allDescWfs = [...wfsInFolder]
|
||||
const allDescFolders: string[] = []
|
||||
|
||||
for (const child of folder.children) {
|
||||
buildMaps(child, [...ancestors, folder.id])
|
||||
allDescFolders.push(child.id, ...(fDescendants[child.id] || []))
|
||||
allDescWfs.push(...(fDescWfs[child.id] || []))
|
||||
}
|
||||
|
||||
fDescendants[folder.id] = allDescFolders
|
||||
fDescWfs[folder.id] = allDescWfs
|
||||
}
|
||||
|
||||
for (const folder of folderTree) {
|
||||
buildMaps(folder, [])
|
||||
}
|
||||
|
||||
const workflowFolderMap = useMemo(() => {
|
||||
const map: Record<string, string> = {}
|
||||
for (const wf of regularWorkflows) {
|
||||
if (wf.folderId) {
|
||||
map[wf.id] = wf.folderId
|
||||
if (wf.folderId && fAncestors[wf.folderId] !== undefined) {
|
||||
wfAncestors[wf.id] = [wf.folderId, ...fAncestors[wf.folderId]]
|
||||
}
|
||||
}
|
||||
return map
|
||||
}, [regularWorkflows])
|
||||
|
||||
const folderWorkflowIds = useMemo(() => {
|
||||
const map: Record<string, string[]> = {}
|
||||
for (const [folderId, workflows] of Object.entries(workflowsByFolder)) {
|
||||
if (folderId !== 'root') {
|
||||
map[folderId] = workflows.map((w) => w.id)
|
||||
}
|
||||
return {
|
||||
workflowAncestorFolderIds: wfAncestors,
|
||||
folderDescendantWorkflowIds: fDescWfs,
|
||||
folderAncestorIds: fAncestors,
|
||||
folderDescendantIds: fDescendants,
|
||||
}
|
||||
return map
|
||||
}, [workflowsByFolder])
|
||||
}, [folderTree, workflowsByFolder, regularWorkflows])
|
||||
|
||||
const { handleWorkflowClick } = useWorkflowSelection({
|
||||
workflowIds: orderedWorkflowIds,
|
||||
activeWorkflowId: workflowId,
|
||||
workflowFolderMap,
|
||||
workflowAncestorFolderIds,
|
||||
})
|
||||
|
||||
const { handleFolderClick } = useFolderSelection({
|
||||
folderIds: orderedFolderIds,
|
||||
folderWorkflowIds,
|
||||
folderDescendantWorkflowIds,
|
||||
folderAncestorIds,
|
||||
folderDescendantIds,
|
||||
})
|
||||
|
||||
const isWorkflowActive = useCallback(
|
||||
@@ -472,9 +562,8 @@ export function WorkflowList({
|
||||
const handleContainerClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target !== e.currentTarget) return
|
||||
const { selectOnly, clearSelection, clearFolderSelection } = useFolderStore.getState()
|
||||
clearFolderSelection()
|
||||
workflowId ? selectOnly(workflowId) : clearSelection()
|
||||
const { selectOnly, clearAllSelection } = useFolderStore.getState()
|
||||
workflowId ? selectOnly(workflowId) : clearAllSelection()
|
||||
},
|
||||
[workflowId]
|
||||
)
|
||||
|
||||
@@ -7,21 +7,37 @@ interface UseFolderSelectionProps {
|
||||
*/
|
||||
folderIds: string[]
|
||||
/**
|
||||
* Map from folder ID to the workflow IDs directly inside that folder
|
||||
* Map from folder ID to ALL descendant workflow IDs (recursively, not just direct children)
|
||||
*/
|
||||
folderWorkflowIds: Record<string, string[]>
|
||||
folderDescendantWorkflowIds: Record<string, string[]>
|
||||
/**
|
||||
* Map from folder ID to all its ancestor folder IDs
|
||||
*/
|
||||
folderAncestorIds: Record<string, string[]>
|
||||
/**
|
||||
* Map from folder ID to all its descendant folder IDs
|
||||
*/
|
||||
folderDescendantIds: Record<string, string[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing folder selection with support for single, range, and toggle selection.
|
||||
* Handles shift-click for range selection and cmd/ctrl-click for toggle selection.
|
||||
* Uses the last selected folder ID (tracked in store) as the anchor point for range selections.
|
||||
* Enforces parent-child constraint: selecting a folder deselects workflows inside it.
|
||||
* Enforces three constraints:
|
||||
* - Selecting a folder deselects any workflows in its entire subtree
|
||||
* - Cmd+click on a folder deselects its ancestors and descendants (clicked folder wins)
|
||||
* - Range selection deduplicates ancestor-descendant pairs (keeps the ancestor)
|
||||
*
|
||||
* @param props - Hook props
|
||||
* @returns Selection handlers
|
||||
*/
|
||||
export function useFolderSelection({ folderIds, folderWorkflowIds }: UseFolderSelectionProps) {
|
||||
export function useFolderSelection({
|
||||
folderIds,
|
||||
folderDescendantWorkflowIds,
|
||||
folderAncestorIds,
|
||||
folderDescendantIds,
|
||||
}: UseFolderSelectionProps) {
|
||||
const {
|
||||
selectedFolders,
|
||||
lastSelectedFolderId,
|
||||
@@ -31,23 +47,67 @@ export function useFolderSelection({ folderIds, folderWorkflowIds }: UseFolderSe
|
||||
} = useFolderStore()
|
||||
|
||||
/**
|
||||
* After a folder selection change, deselect any workflows whose parent folder is selected
|
||||
* to prevent parent-child co-selection.
|
||||
* Deselect any workflows whose folder (or any ancestor folder) is currently selected.
|
||||
*/
|
||||
const deselectConflictingWorkflows = useCallback(() => {
|
||||
const { selectedWorkflows: workflows, selectedFolders: folders } = useFolderStore.getState()
|
||||
if (workflows.size === 0) return
|
||||
|
||||
for (const folderId of folders) {
|
||||
const wfIdsInFolder = folderWorkflowIds[folderId]
|
||||
if (!wfIdsInFolder) continue
|
||||
for (const wfId of wfIdsInFolder) {
|
||||
const wfIds = folderDescendantWorkflowIds[folderId]
|
||||
if (!wfIds) continue
|
||||
for (const wfId of wfIds) {
|
||||
if (workflows.has(wfId)) {
|
||||
useFolderStore.getState().deselectWorkflow(wfId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [folderWorkflowIds])
|
||||
}, [folderDescendantWorkflowIds])
|
||||
|
||||
/**
|
||||
* For Cmd+click: the clicked folder wins. Deselect any selected folders that are
|
||||
* ancestors or descendants of the clicked folder.
|
||||
*/
|
||||
const deselectRelatedFolders = useCallback(
|
||||
(clickedFolderId: string) => {
|
||||
const { selectedFolders: folders } = useFolderStore.getState()
|
||||
if (!folders.has(clickedFolderId) || folders.size <= 1) return
|
||||
|
||||
const ancestors = folderAncestorIds[clickedFolderId] || []
|
||||
const descendants = folderDescendantIds[clickedFolderId] || []
|
||||
|
||||
for (const id of ancestors) {
|
||||
if (folders.has(id)) {
|
||||
useFolderStore.getState().deselectFolder(id)
|
||||
}
|
||||
}
|
||||
for (const id of descendants) {
|
||||
if (folders.has(id)) {
|
||||
useFolderStore.getState().deselectFolder(id)
|
||||
}
|
||||
}
|
||||
},
|
||||
[folderAncestorIds, folderDescendantIds]
|
||||
)
|
||||
|
||||
/**
|
||||
* For range selection: if both a folder and a nested subfolder end up in the range,
|
||||
* keep the ancestor and deselect the descendant (ancestor already covers it).
|
||||
*/
|
||||
const deduplicateSelectedFolders = useCallback(() => {
|
||||
const { selectedFolders: folders } = useFolderStore.getState()
|
||||
if (folders.size <= 1) return
|
||||
|
||||
for (const folderId of folders) {
|
||||
const ancestors = folderAncestorIds[folderId] || []
|
||||
for (const ancestorId of ancestors) {
|
||||
if (folders.has(ancestorId)) {
|
||||
useFolderStore.getState().deselectFolder(folderId)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [folderAncestorIds])
|
||||
|
||||
/**
|
||||
* 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
|
||||
(folderId: string, shiftKey: boolean, metaKey: boolean) => {
|
||||
if (metaKey) {
|
||||
toggleFolderSelection(folderId)
|
||||
deselectRelatedFolders(folderId)
|
||||
deselectConflictingWorkflows()
|
||||
} else if (shiftKey && lastSelectedFolderId && lastSelectedFolderId !== folderId) {
|
||||
selectFolderRange(folderIds, lastSelectedFolderId, folderId)
|
||||
deduplicateSelectedFolders()
|
||||
deselectConflictingWorkflows()
|
||||
} else if (shiftKey) {
|
||||
selectFolderOnly(folderId)
|
||||
@@ -78,6 +140,8 @@ export function useFolderSelection({ folderIds, folderWorkflowIds }: UseFolderSe
|
||||
selectFolderOnly,
|
||||
selectFolderRange,
|
||||
toggleFolderSelection,
|
||||
deselectRelatedFolders,
|
||||
deduplicateSelectedFolders,
|
||||
deselectConflictingWorkflows,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -11,16 +11,16 @@ interface UseWorkflowSelectionProps {
|
||||
*/
|
||||
activeWorkflowId: string | undefined
|
||||
/**
|
||||
* Map from workflow ID to its parent folder ID (only for workflows inside folders)
|
||||
* Map from workflow ID to all its ancestor folder IDs (direct parent first, then up)
|
||||
*/
|
||||
workflowFolderMap: Record<string, string>
|
||||
workflowAncestorFolderIds: Record<string, string[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing workflow selection with support for single, range, and toggle selection.
|
||||
* Handles shift-click for range selection and regular click for single selection.
|
||||
* Uses the active workflow ID as the anchor point for range selections.
|
||||
* Enforces parent-child constraint: selecting a workflow deselects its parent folder.
|
||||
* Enforces ancestor constraint: selecting a workflow deselects any ancestor folder.
|
||||
*
|
||||
* @param props - Hook props
|
||||
* @returns Selection handlers
|
||||
@@ -28,25 +28,28 @@ interface UseWorkflowSelectionProps {
|
||||
export function useWorkflowSelection({
|
||||
workflowIds,
|
||||
activeWorkflowId,
|
||||
workflowFolderMap,
|
||||
workflowAncestorFolderIds,
|
||||
}: UseWorkflowSelectionProps) {
|
||||
const { selectedWorkflows, selectOnly, selectRange, toggleWorkflowSelection } = useFolderStore()
|
||||
|
||||
/**
|
||||
* After a workflow selection change, deselect any folders that contain selected workflows
|
||||
* to prevent parent-child co-selection.
|
||||
* After a workflow selection change, deselect any folder that is an ancestor of a selected
|
||||
* workflow to prevent ancestor-descendant co-selection.
|
||||
*/
|
||||
const deselectConflictingFolders = useCallback(() => {
|
||||
const { selectedWorkflows: workflows, selectedFolders: folders } = useFolderStore.getState()
|
||||
if (folders.size === 0) return
|
||||
|
||||
for (const wfId of workflows) {
|
||||
const folderId = workflowFolderMap[wfId]
|
||||
if (folderId && folders.has(folderId)) {
|
||||
useFolderStore.getState().deselectFolder(folderId)
|
||||
const ancestorIds = workflowAncestorFolderIds[wfId]
|
||||
if (!ancestorIds) continue
|
||||
for (const folderId of ancestorIds) {
|
||||
if (folders.has(folderId)) {
|
||||
useFolderStore.getState().deselectFolder(folderId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [workflowFolderMap])
|
||||
}, [workflowAncestorFolderIds])
|
||||
|
||||
/**
|
||||
* Handle workflow click with support for shift-click range selection and cmd/ctrl-click toggle.
|
||||
|
||||
Reference in New Issue
Block a user