From a6a9962cb3b274114a92e4073b3464671993e5d0 Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 11 Nov 2025 12:55:45 -0800 Subject: [PATCH] feat(registry): support multi-workflow delete (#1897) * feat(registry): support multi-workflow delete * added intelligent next index selection if deleting active workflow --- .../components/e2b-switch/e2b-switch.tsx | 61 ------------ .../components/sub-block/components/index.ts | 1 - .../editor/components/sub-block/sub-block.tsx | 13 --- .../components/panel-new/panel-new.tsx | 2 +- .../components/context-menu/context-menu.tsx | 26 +++-- .../components/delete-modal/delete-modal.tsx | 40 ++++++-- .../workflow-item/workflow-item.tsx | 77 +++++++++++++-- .../w/hooks/use-delete-workflow.ts | 99 +++++++++++++++---- apps/sim/components/icons.tsx | 6 +- 9 files changed, 199 insertions(+), 126 deletions(-) delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/e2b-switch/e2b-switch.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/e2b-switch/e2b-switch.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/e2b-switch/e2b-switch.tsx deleted file mode 100644 index 22727dd7a..000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/e2b-switch/e2b-switch.tsx +++ /dev/null @@ -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(blockId, subBlockId) - const value = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue - - const handleChange = (checked: boolean) => { - if (!isPreview && !disabled) setStoreValue(checked) - } - - return ( -
- - - - - - - - Python/Javascript code run in a sandbox environment. Can have slower execution times. - - -
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/index.ts index 01e7256bb..a28fbe7c4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/index.ts @@ -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' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/sub-block.tsx index 37147e72c..efc7efc0f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/sub-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/sub-block.tsx @@ -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 ( - - ) - } return ( activeWorkflowId || '', isActive: true, onSuccess: () => setIsDeleteModalOpen(false), }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/context-menu/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/context-menu/context-menu.tsx index cc6ba7332..60def668c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/context-menu/context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/context-menu/context-menu.tsx @@ -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 ( @@ -58,15 +64,17 @@ export function ContextMenu({ }} /> - { - onRename() - onClose() - }} - > - - Rename - + {showRename && ( + { + onRename() + onClose() + }} + > + + Rename + + )} { onDelete() diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/delete-modal/delete-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/delete-modal/delete-modal.tsx index 3e4ef1c25..46d9ab056 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/delete-modal/delete-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/delete-modal/delete-modal.tsx @@ -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 ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/workflow-item/workflow-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/workflow-item/workflow-item.tsx index 9e8368201..65d6b1fc8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/workflow-item/workflow-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/workflow-item/workflow-item.tsx @@ -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([]) + const [deleteModalNames, setDeleteModalNames] = useState('') + + // 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} /> ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-workflow.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-workflow.ts index a13616fe3..b77229fa1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-workflow.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-workflow.ts @@ -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, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 71ec05793..9aab11005 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -3867,9 +3867,9 @@ export function HumanInTheLoopIcon(props: SVGProps) { viewBox='0 0 24 24' fill='none' stroke='currentColor' - stroke-width='2' - stroke-linecap='round' - stroke-linejoin='round' + strokeWidth='2' + strokeLinecap='round' + strokeLinejoin='round' >