fix(selections): more nested folder inaccuracies

This commit is contained in:
Vikhyath Mondreti
2026-03-09 11:17:43 -07:00
parent 71d8e227bd
commit e921448bf2
3 changed files with 201 additions and 45 deletions

View File

@@ -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]
)

View File

@@ -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,
]
)

View File

@@ -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.