improvement(folder-selection): folder deselection + selection order should match visual

This commit is contained in:
Vikhyath Mondreti
2026-03-09 11:00:22 -07:00
parent 4593a8a471
commit 71d8e227bd
4 changed files with 171 additions and 35 deletions

View File

@@ -146,23 +146,77 @@ export function WorkflowList({
const orderedWorkflowIds = useMemo(() => {
const ids: string[] = []
const collectWorkflowIds = (folder: FolderTreeNode) => {
const collectFromFolder = (folder: FolderTreeNode) => {
const workflowsInFolder = workflowsByFolder[folder.id] || []
for (const workflow of workflowsInFolder) {
ids.push(workflow.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 childFolder of folder.children) {
collectWorkflowIds(childFolder)
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 === 'workflow') {
ids.push(item.id)
} else {
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) {
collectWorkflowIds(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)
const rootWorkflows = workflowsByFolder.root || []
for (const workflow of rootWorkflows) {
ids.push(workflow.id)
for (const item of rootLevelItems) {
if (item.type === 'workflow') {
ids.push(item.id)
} else {
collectFromFolder(item.data as FolderTreeNode)
}
}
return ids
@@ -185,13 +239,35 @@ export function WorkflowList({
return ids
}, [folderTree])
const workflowFolderMap = useMemo(() => {
const map: Record<string, string> = {}
for (const wf of regularWorkflows) {
if (wf.folderId) {
map[wf.id] = 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 map
}, [workflowsByFolder])
const { handleWorkflowClick } = useWorkflowSelection({
workflowIds: orderedWorkflowIds,
activeWorkflowId: workflowId,
workflowFolderMap,
})
const { handleFolderClick } = useFolderSelection({
folderIds: orderedFolderIds,
folderWorkflowIds,
})
const isWorkflowActive = useCallback(

View File

@@ -6,17 +6,22 @@ interface UseFolderSelectionProps {
* Flat array of all folder IDs in display order
*/
folderIds: string[]
/**
* Map from folder ID to the workflow IDs directly inside that folder
*/
folderWorkflowIds: 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.
*
* @param props - Hook props
* @returns Selection handlers
*/
export function useFolderSelection({ folderIds }: UseFolderSelectionProps) {
export function useFolderSelection({ folderIds, folderWorkflowIds }: UseFolderSelectionProps) {
const {
selectedFolders,
lastSelectedFolderId,
@@ -25,6 +30,25 @@ export function useFolderSelection({ folderIds }: UseFolderSelectionProps) {
toggleFolderSelection,
} = useFolderStore()
/**
* After a folder selection change, deselect any workflows whose parent folder is selected
* to prevent parent-child co-selection.
*/
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) {
if (workflows.has(wfId)) {
useFolderStore.getState().deselectWorkflow(wfId)
}
}
}
}, [folderWorkflowIds])
/**
* Handle folder click with support for shift-click range selection and cmd/ctrl-click toggle
*
@@ -34,24 +58,28 @@ export function useFolderSelection({ folderIds }: UseFolderSelectionProps) {
*/
const handleFolderClick = useCallback(
(folderId: string, shiftKey: boolean, metaKey: boolean) => {
// Cmd/Ctrl+Click: Toggle individual selection
if (metaKey) {
toggleFolderSelection(folderId)
}
// Shift+Click: Range selection from last selected folder to clicked folder
else if (shiftKey && lastSelectedFolderId && lastSelectedFolderId !== folderId) {
deselectConflictingWorkflows()
} else if (shiftKey && lastSelectedFolderId && lastSelectedFolderId !== folderId) {
selectFolderRange(folderIds, lastSelectedFolderId, folderId)
}
// Shift+Click without anchor: Select only this folder (establishes anchor)
else if (shiftKey) {
deselectConflictingWorkflows()
} else if (shiftKey) {
selectFolderOnly(folderId)
}
// Regular click: Select only this folder
else {
deselectConflictingWorkflows()
} else {
selectFolderOnly(folderId)
deselectConflictingWorkflows()
}
},
[folderIds, lastSelectedFolderId, selectFolderOnly, selectFolderRange, toggleFolderSelection]
[
folderIds,
lastSelectedFolderId,
selectFolderOnly,
selectFolderRange,
toggleFolderSelection,
deselectConflictingWorkflows,
]
)
return {

View File

@@ -10,22 +10,46 @@ interface UseWorkflowSelectionProps {
* Active workflow ID (from URL) - used as anchor for range selection
*/
activeWorkflowId: string | undefined
/**
* Map from workflow ID to its parent folder ID (only for workflows inside folders)
*/
workflowFolderMap: 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.
*
* @param props - Hook props
* @returns Selection handlers
*/
export function useWorkflowSelection({ workflowIds, activeWorkflowId }: UseWorkflowSelectionProps) {
export function useWorkflowSelection({
workflowIds,
activeWorkflowId,
workflowFolderMap,
}: 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.
*/
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)
}
}
}, [workflowFolderMap])
/**
* Handle workflow click with support for shift-click range selection and cmd/ctrl-click toggle.
* Does not clear folder selection to allow unified selection of workflows and folders.
*
* @param workflowId - ID of clicked workflow
* @param shiftKey - Whether shift key was pressed
@@ -33,24 +57,27 @@ export function useWorkflowSelection({ workflowIds, activeWorkflowId }: UseWorkf
*/
const handleWorkflowClick = useCallback(
(workflowId: string, shiftKey: boolean, metaKey: boolean) => {
// Cmd/Ctrl+Click: Toggle individual selection
if (metaKey) {
toggleWorkflowSelection(workflowId)
}
// Shift+Click: Range selection from active workflow to clicked workflow
else if (shiftKey && activeWorkflowId && activeWorkflowId !== workflowId) {
deselectConflictingFolders()
} else if (shiftKey && activeWorkflowId && activeWorkflowId !== workflowId) {
selectRange(workflowIds, activeWorkflowId, workflowId)
}
// Shift+Click without active workflow: Toggle selection
else if (shiftKey) {
deselectConflictingFolders()
} else if (shiftKey) {
toggleWorkflowSelection(workflowId)
}
// Regular click: Select only this workflow (preserves folder selection for unified multi-select)
else {
deselectConflictingFolders()
} else {
selectOnly(workflowId)
}
},
[workflowIds, activeWorkflowId, selectOnly, selectRange, toggleWorkflowSelection]
[
workflowIds,
activeWorkflowId,
selectOnly,
selectRange,
toggleWorkflowSelection,
deselectConflictingFolders,
]
)
return {

View File

@@ -117,7 +117,12 @@ export const useFolderStore = create<FolderState>()(
clearSelection: () => set({ selectedWorkflows: new Set() }),
selectOnly: (workflowId) => set({ selectedWorkflows: new Set([workflowId]) }),
selectOnly: (workflowId) =>
set({
selectedWorkflows: new Set([workflowId]),
selectedFolders: new Set(),
lastSelectedFolderId: null,
}),
selectRange: (workflowIds, fromId, toId) => {
const fromIndex = workflowIds.indexOf(fromId)