feat(registry): support multi-workflow delete (#1897)

* feat(registry): support multi-workflow delete

* added intelligent next index selection if deleting active workflow
This commit is contained in:
Waleed
2025-11-11 12:55:45 -08:00
committed by GitHub
parent 16bd54c05a
commit a6a9962cb3
9 changed files with 199 additions and 126 deletions

View File

@@ -1,61 +0,0 @@
import { Info } from 'lucide-react'
import { Tooltip } from '@/components/emcn'
import { Label } from '@/components/ui'
import { Switch as UISwitch } from '@/components/ui/switch'
import { getEnv, isTruthy } from '@/lib/env'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value'
interface E2BSwitchProps {
blockId: string
subBlockId: string
title: string
value?: boolean
isPreview?: boolean
previewValue?: boolean | null
disabled?: boolean
}
export function E2BSwitch({
blockId,
subBlockId,
title,
value: propValue,
isPreview = false,
previewValue,
disabled = false,
}: E2BSwitchProps) {
const e2bEnabled = isTruthy(getEnv('NEXT_PUBLIC_E2B_ENABLED'))
if (!e2bEnabled) return null
const [storeValue, setStoreValue] = useSubBlockValue<boolean>(blockId, subBlockId)
const value = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue
const handleChange = (checked: boolean) => {
if (!isPreview && !disabled) setStoreValue(checked)
}
return (
<div className='flex items-center gap-2'>
<UISwitch
id={`${blockId}-${subBlockId}`}
checked={Boolean(value)}
onCheckedChange={handleChange}
disabled={isPreview || disabled}
/>
<Label
htmlFor={`${blockId}-${subBlockId}`}
className='cursor-pointer font-normal text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
>
{title}
</Label>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Info className='h-4 w-4 cursor-pointer text-muted-foreground' />
</Tooltip.Trigger>
<Tooltip.Content side='top' className='max-w-[320px] select-text whitespace-pre-wrap'>
Python/Javascript code run in a sandbox environment. Can have slower execution times.
</Tooltip.Content>
</Tooltip.Root>
</div>
)
}

View File

@@ -7,7 +7,6 @@ export { CredentialSelector } from './credential-selector/credential-selector'
export { DocumentSelector } from './document-selector/document-selector'
export { DocumentTagEntry } from './document-tag-entry/document-tag-entry'
export { Dropdown } from './dropdown/dropdown'
export { E2BSwitch } from './e2b-switch/e2b-switch'
export { EvalInput } from './eval-input/eval-input'
export { FileSelectorInput } from './file-selector/file-selector-input'
export { FileUpload } from './file-upload/file-upload'

View File

@@ -14,7 +14,6 @@ import {
DocumentSelector,
DocumentTagEntry,
Dropdown,
E2BSwitch,
EvalInput,
FileSelectorInput,
FileUpload,
@@ -291,18 +290,6 @@ function SubBlockComponent({
)
case 'switch':
if (config.id === 'remoteExecution') {
return (
<E2BSwitch
blockId={blockId}
subBlockId={config.id}
title={config.title ?? ''}
isPreview={isPreview}
previewValue={previewValue as any}
disabled={isDisabled}
/>
)
}
return (
<Switch
blockId={blockId}

View File

@@ -85,7 +85,7 @@ export function Panel() {
// Delete workflow hook
const { isDeleting, handleDeleteWorkflow } = useDeleteWorkflow({
workspaceId,
workflowId: activeWorkflowId || '',
getWorkflowIds: () => activeWorkflowId || '',
isActive: true,
onSuccess: () => setIsDeleteModalOpen(false),
})

View File

@@ -29,6 +29,11 @@ interface ContextMenuProps {
* Callback when delete is clicked
*/
onDelete: () => void
/**
* Whether to show the rename option (default: true)
* Set to false when multiple items are selected
*/
showRename?: boolean
}
/**
@@ -45,6 +50,7 @@ export function ContextMenu({
onClose,
onRename,
onDelete,
showRename = true,
}: ContextMenuProps) {
return (
<Popover open={isOpen} onOpenChange={onClose}>
@@ -58,15 +64,17 @@ export function ContextMenu({
}}
/>
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
<PopoverItem
onClick={() => {
onRename()
onClose()
}}
>
<Pencil className='h-3 w-3' />
<span>Rename</span>
</PopoverItem>
{showRename && (
<PopoverItem
onClick={() => {
onRename()
onClose()
}}
>
<Pencil className='h-3 w-3' />
<span>Rename</span>
</PopoverItem>
)}
<PopoverItem
onClick={() => {
onDelete()

View File

@@ -32,9 +32,10 @@ interface DeleteModalProps {
*/
itemType: 'workflow' | 'folder'
/**
* Name of the item being deleted (optional, for display)
* Name(s) of the item(s) being deleted (optional, for display)
* Can be a single name or an array of names for multiple items
*/
itemName?: string
itemName?: string | string[]
}
/**
@@ -52,12 +53,37 @@ export function DeleteModal({
itemType,
itemName,
}: DeleteModalProps) {
const title = itemType === 'workflow' ? 'Delete workflow?' : 'Delete folder?'
const isMultiple = Array.isArray(itemName) && itemName.length > 1
const isSingle = !isMultiple
const description =
itemType === 'workflow'
? 'Deleting this workflow will permanently remove all associated blocks, executions, and configuration.'
: 'Deleting this folder will permanently remove all associated workflows, logs, and knowledge bases.'
const displayNames = Array.isArray(itemName) ? itemName : itemName ? [itemName] : []
let title = ''
if (itemType === 'workflow') {
title = isMultiple ? 'Delete workflows?' : 'Delete workflow?'
} else {
title = 'Delete folder?'
}
let description = ''
if (itemType === 'workflow') {
if (isMultiple) {
const workflowList = displayNames.join(', ')
description = `Deleting ${workflowList} will permanently remove all associated blocks, executions, and configuration.`
} else if (isSingle && displayNames.length > 0) {
description = `Deleting ${displayNames[0]} will permanently remove all associated blocks, executions, and configuration.`
} else {
description =
'Deleting this workflow will permanently remove all associated blocks, executions, and configuration.'
}
} else {
if (isSingle && displayNames.length > 0) {
description = `Deleting ${displayNames[0]} will permanently remove all associated workflows, logs, and knowledge bases.`
} else {
description =
'Deleting this folder will permanently remove all associated workflows, logs, and knowledge bases.'
}
}
return (
<Modal open={isOpen} onOpenChange={onClose}>

View File

@@ -1,10 +1,9 @@
'use client'
import { useCallback, useState } from 'react'
import { useCallback, useRef, useState } from 'react'
import clsx from 'clsx'
import Link from 'next/link'
import { useParams } from 'next/navigation'
import { createLogger } from '@/lib/logs/console/logger'
import { useDeleteWorkflow } from '@/app/workspace/[workspaceId]/w/hooks'
import { useFolderStore } from '@/stores/folders/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -13,8 +12,6 @@ import { useContextMenu, useItemDrag, useItemRename } from '../../../../hooks'
import { ContextMenu } from '../context-menu/context-menu'
import { DeleteModal } from '../delete-modal/delete-modal'
const logger = createLogger('WorkflowItem')
interface WorkflowItemProps {
workflow: WorkflowMetadata
active: boolean
@@ -33,17 +30,37 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
const params = useParams()
const workspaceId = params.workspaceId as string
const { selectedWorkflows } = useFolderStore()
const { updateWorkflow } = useWorkflowRegistry()
const { updateWorkflow, workflows } = useWorkflowRegistry()
const isSelected = selectedWorkflows.has(workflow.id)
// Delete modal state
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
const [workflowIdsToDelete, setWorkflowIdsToDelete] = useState<string[]>([])
const [deleteModalNames, setDeleteModalNames] = useState<string | string[]>('')
// Capture selection at right-click time (using ref to persist across renders)
const capturedSelectionRef = useRef<{
workflowIds: string[]
workflowNames: string | string[]
} | null>(null)
/**
* Handle opening the delete modal - uses pre-captured selection state
*/
const handleOpenDeleteModal = useCallback(() => {
// Use the selection captured at right-click time
if (capturedSelectionRef.current) {
setWorkflowIdsToDelete(capturedSelectionRef.current.workflowIds)
setDeleteModalNames(capturedSelectionRef.current.workflowNames)
setIsDeleteModalOpen(true)
}
}, [])
// Delete workflow hook
const { isDeleting, handleDeleteWorkflow } = useDeleteWorkflow({
workspaceId,
workflowId: workflow.id,
isActive: active,
getWorkflowIds: () => workflowIdsToDelete,
isActive: (workflowIds) => workflowIds.includes(params.workflowId as string),
onSuccess: () => setIsDeleteModalOpen(false),
})
@@ -79,10 +96,49 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
isOpen: isContextMenuOpen,
position,
menuRef,
handleContextMenu,
handleContextMenu: handleContextMenuBase,
closeMenu,
} = useContextMenu()
/**
* Handle right-click - ensure proper selection behavior and capture selection state
* If right-clicking on an unselected workflow, select only that workflow
* If right-clicking on a selected workflow with multiple selections, keep all selections
*/
const handleContextMenu = useCallback(
(e: React.MouseEvent) => {
// Check current selection state at time of right-click
const { selectedWorkflows: currentSelection, selectOnly } = useFolderStore.getState()
const isCurrentlySelected = currentSelection.has(workflow.id)
// If this workflow is not in the current selection, select only this workflow
if (!isCurrentlySelected) {
selectOnly(workflow.id)
}
// Capture the selection state at right-click time
const finalSelection = useFolderStore.getState().selectedWorkflows
const finalIsSelected = finalSelection.has(workflow.id)
const workflowIds =
finalIsSelected && finalSelection.size > 1 ? Array.from(finalSelection) : [workflow.id]
const workflowNames = workflowIds
.map((id) => workflows[id]?.name)
.filter((name): name is string => !!name)
// Store in ref so it persists even if selection changes
capturedSelectionRef.current = {
workflowIds,
workflowNames: workflowNames.length > 1 ? workflowNames : workflowNames[0],
}
// If already selected with multiple selections, keep all selections
handleContextMenuBase(e)
},
[workflow.id, workflows, handleContextMenuBase]
)
// Rename hook
const {
isEditing,
@@ -197,7 +253,8 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
menuRef={menuRef}
onClose={closeMenu}
onRename={handleStartEdit}
onDelete={() => setIsDeleteModalOpen(true)}
onDelete={handleOpenDeleteModal}
showRename={selectedWorkflows.size <= 1}
/>
{/* Delete Confirmation Modal */}
@@ -207,7 +264,7 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
onConfirm={handleDeleteWorkflow}
isDeleting={isDeleting}
itemType='workflow'
itemName={workflow.name}
itemName={deleteModalNames}
/>
</>
)

View File

@@ -1,6 +1,7 @@
import { useCallback, useState } from 'react'
import { useRouter } from 'next/navigation'
import { createLogger } from '@/lib/logs/console/logger'
import { useFolderStore } from '@/stores/folders/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('useDeleteWorkflow')
@@ -11,13 +12,15 @@ interface UseDeleteWorkflowProps {
*/
workspaceId: string
/**
* ID of the workflow to delete
* Function that returns the workflow ID(s) to delete
* This function is called when deletion occurs to get fresh selection state
*/
workflowId: string
getWorkflowIds: () => string | string[]
/**
* Whether this is the currently active workflow
* Whether the active workflow is being deleted
* Can be a boolean or a function that receives the workflow IDs
*/
isActive?: boolean
isActive?: boolean | ((workflowIds: string[]) => boolean)
/**
* Optional callback after successful deletion
*/
@@ -28,9 +31,10 @@ interface UseDeleteWorkflowProps {
* Hook for managing workflow deletion with navigation logic.
*
* Handles:
* - Single or bulk workflow deletion
* - Finding next workflow to navigate to
* - Navigating before deletion (if active workflow)
* - Removing workflow from registry
* - Removing workflow(s) from registry
* - Loading state management
* - Error handling and logging
*
@@ -39,7 +43,7 @@ interface UseDeleteWorkflowProps {
*/
export function useDeleteWorkflow({
workspaceId,
workflowId,
getWorkflowIds,
isActive = false,
onSuccess,
}: UseDeleteWorkflowProps) {
@@ -48,30 +52,70 @@ export function useDeleteWorkflow({
const [isDeleting, setIsDeleting] = useState(false)
/**
* Delete the workflow and navigate if needed
* Delete the workflow(s) and navigate if needed
*/
const handleDeleteWorkflow = useCallback(async () => {
if (!workflowId || isDeleting) {
if (isDeleting) {
return
}
setIsDeleting(true)
try {
// Find next workflow to navigate to
// Get fresh workflow IDs at deletion time
const workflowIdsOrId = getWorkflowIds()
if (!workflowIdsOrId) {
return
}
// Normalize to array for consistent handling
const workflowIdsToDelete = Array.isArray(workflowIdsOrId)
? workflowIdsOrId
: [workflowIdsOrId]
// Determine if active workflow is being deleted
const isActiveWorkflowBeingDeleted =
typeof isActive === 'function' ? isActive(workflowIdsToDelete) : isActive
// Find next workflow to navigate to (if active workflow is being deleted)
const sidebarWorkflows = Object.values(workflows).filter((w) => w.workspaceId === workspaceId)
const currentIndex = sidebarWorkflows.findIndex((w) => w.id === workflowId)
// Find which specific workflow is the active one (if any in the deletion list)
let activeWorkflowId: string | null = null
if (isActiveWorkflowBeingDeleted && typeof isActive === 'function') {
// Check each workflow being deleted to find which one is active
activeWorkflowId =
workflowIdsToDelete.find((id) => isActive([id])) || workflowIdsToDelete[0]
} else {
activeWorkflowId = workflowIdsToDelete[0]
}
const currentIndex = sidebarWorkflows.findIndex((w) => w.id === activeWorkflowId)
let nextWorkflowId: string | null = null
if (sidebarWorkflows.length > 1) {
if (currentIndex < sidebarWorkflows.length - 1) {
nextWorkflowId = sidebarWorkflows[currentIndex + 1].id
} else if (currentIndex > 0) {
nextWorkflowId = sidebarWorkflows[currentIndex - 1].id
if (isActiveWorkflowBeingDeleted && sidebarWorkflows.length > workflowIdsToDelete.length) {
// Find the first workflow that's not being deleted
const remainingWorkflows = sidebarWorkflows.filter(
(w) => !workflowIdsToDelete.includes(w.id)
)
if (remainingWorkflows.length > 0) {
// Try to find the next workflow after the current one
const workflowsAfterCurrent = remainingWorkflows.filter((w) => {
const idx = sidebarWorkflows.findIndex((sw) => sw.id === w.id)
return idx > currentIndex
})
if (workflowsAfterCurrent.length > 0) {
nextWorkflowId = workflowsAfterCurrent[0].id
} else {
// Otherwise, use the first remaining workflow
nextWorkflowId = remainingWorkflows[0].id
}
}
}
// Navigate first if this is the active workflow
if (isActive) {
if (isActiveWorkflowBeingDeleted) {
if (nextWorkflowId) {
router.push(`/workspace/${workspaceId}/w/${nextWorkflowId}`)
} else {
@@ -79,18 +123,31 @@ export function useDeleteWorkflow({
}
}
// Then delete
await removeWorkflow(workflowId)
// Delete all workflows
await Promise.all(workflowIdsToDelete.map((id) => removeWorkflow(id)))
logger.info('Workflow deleted successfully', { workflowId })
// Clear selection after successful deletion
const { clearSelection } = useFolderStore.getState()
clearSelection()
logger.info('Workflow(s) deleted successfully', { workflowIds: workflowIdsToDelete })
onSuccess?.()
} catch (error) {
logger.error('Error deleting workflow:', { error, workflowId })
logger.error('Error deleting workflow(s):', { error })
throw error
} finally {
setIsDeleting(false)
}
}, [workflowId, isDeleting, workflows, workspaceId, isActive, router, removeWorkflow, onSuccess])
}, [
getWorkflowIds,
isDeleting,
workflows,
workspaceId,
isActive,
router,
removeWorkflow,
onSuccess,
])
return {
isDeleting,

View File

@@ -3867,9 +3867,9 @@ export function HumanInTheLoopIcon(props: SVGProps<SVGSVGElement>) {
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
stroke-width='2'
stroke-linecap='round'
stroke-linejoin='round'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
>
<path d='M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2' />
<circle cx='12' cy='7' r='4' />