mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-10 07:27:57 -05:00
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -85,7 +85,7 @@ export function Panel() {
|
||||
// Delete workflow hook
|
||||
const { isDeleting, handleDeleteWorkflow } = useDeleteWorkflow({
|
||||
workspaceId,
|
||||
workflowId: activeWorkflowId || '',
|
||||
getWorkflowIds: () => activeWorkflowId || '',
|
||||
isActive: true,
|
||||
onSuccess: () => setIsDeleteModalOpen(false),
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' />
|
||||
|
||||
Reference in New Issue
Block a user