mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
feat(duplicate): added folder duplication, add duplicate to sidebar context menu (#1902)
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflow, workflowFolder } from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getUserEntityPermissions } from '@/lib/permissions/utils'
|
||||
import { generateRequestId } from '@/lib/utils'
|
||||
import { duplicateWorkflow } from '@/lib/workflows/duplicate'
|
||||
|
||||
const logger = createLogger('FolderDuplicateAPI')
|
||||
|
||||
const DuplicateRequestSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
workspaceId: z.string().optional(),
|
||||
parentId: z.string().nullable().optional(),
|
||||
color: z.string().optional(),
|
||||
})
|
||||
|
||||
// POST /api/folders/[id]/duplicate - Duplicate a folder with all its child folders and workflows
|
||||
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id: sourceFolderId } = await params
|
||||
const requestId = generateRequestId()
|
||||
const startTime = Date.now()
|
||||
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized folder duplication attempt for ${sourceFolderId}`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await req.json()
|
||||
const { name, workspaceId, parentId, color } = DuplicateRequestSchema.parse(body)
|
||||
|
||||
logger.info(`[${requestId}] Duplicating folder ${sourceFolderId} for user ${session.user.id}`)
|
||||
|
||||
// Verify the source folder exists
|
||||
const sourceFolder = await db
|
||||
.select()
|
||||
.from(workflowFolder)
|
||||
.where(eq(workflowFolder.id, sourceFolderId))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!sourceFolder) {
|
||||
throw new Error('Source folder not found')
|
||||
}
|
||||
|
||||
// Check if user has permission to access the source folder
|
||||
const userPermission = await getUserEntityPermissions(
|
||||
session.user.id,
|
||||
'workspace',
|
||||
sourceFolder.workspaceId
|
||||
)
|
||||
|
||||
if (!userPermission || userPermission === 'read') {
|
||||
throw new Error('Source folder not found or access denied')
|
||||
}
|
||||
|
||||
const targetWorkspaceId = workspaceId || sourceFolder.workspaceId
|
||||
|
||||
// Step 1: Duplicate folder structure
|
||||
const { newFolderId, folderMapping } = await db.transaction(async (tx) => {
|
||||
const newFolderId = crypto.randomUUID()
|
||||
const now = new Date()
|
||||
|
||||
// Create the new root folder
|
||||
await tx.insert(workflowFolder).values({
|
||||
id: newFolderId,
|
||||
userId: session.user.id,
|
||||
workspaceId: targetWorkspaceId,
|
||||
name,
|
||||
color: color || sourceFolder.color,
|
||||
parentId: parentId || sourceFolder.parentId,
|
||||
sortOrder: sourceFolder.sortOrder,
|
||||
isExpanded: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
// Recursively duplicate child folders
|
||||
const folderMapping = new Map<string, string>([[sourceFolderId, newFolderId]])
|
||||
await duplicateFolderStructure(
|
||||
tx,
|
||||
sourceFolderId,
|
||||
newFolderId,
|
||||
sourceFolder.workspaceId,
|
||||
targetWorkspaceId,
|
||||
session.user.id,
|
||||
now,
|
||||
folderMapping
|
||||
)
|
||||
|
||||
return { newFolderId, folderMapping }
|
||||
})
|
||||
|
||||
// Step 2: Duplicate workflows
|
||||
const workflowStats = await duplicateWorkflowsInFolderTree(
|
||||
sourceFolder.workspaceId,
|
||||
targetWorkspaceId,
|
||||
folderMapping,
|
||||
session.user.id,
|
||||
requestId
|
||||
)
|
||||
|
||||
const elapsed = Date.now() - startTime
|
||||
logger.info(
|
||||
`[${requestId}] Successfully duplicated folder ${sourceFolderId} to ${newFolderId} in ${elapsed}ms`,
|
||||
{
|
||||
foldersCount: folderMapping.size,
|
||||
workflowsCount: workflowStats.total,
|
||||
workflowsSucceeded: workflowStats.succeeded,
|
||||
workflowsFailed: workflowStats.failed,
|
||||
}
|
||||
)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
id: newFolderId,
|
||||
name,
|
||||
color: color || sourceFolder.color,
|
||||
workspaceId: targetWorkspaceId,
|
||||
parentId: parentId || sourceFolder.parentId,
|
||||
foldersCount: folderMapping.size,
|
||||
workflowsCount: workflowStats.succeeded,
|
||||
},
|
||||
{ status: 201 }
|
||||
)
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === 'Source folder not found') {
|
||||
logger.warn(`[${requestId}] Source folder ${sourceFolderId} not found`)
|
||||
return NextResponse.json({ error: 'Source folder not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (error.message === 'Source folder not found or access denied') {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${session.user.id} denied access to source folder ${sourceFolderId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
}
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn(`[${requestId}] Invalid duplication request data`, { errors: error.errors })
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request data', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - startTime
|
||||
logger.error(
|
||||
`[${requestId}] Error duplicating folder ${sourceFolderId} after ${elapsed}ms:`,
|
||||
error
|
||||
)
|
||||
return NextResponse.json({ error: 'Failed to duplicate folder' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to recursively duplicate folder structure
|
||||
async function duplicateFolderStructure(
|
||||
tx: any,
|
||||
sourceFolderId: string,
|
||||
newParentFolderId: string,
|
||||
sourceWorkspaceId: string,
|
||||
targetWorkspaceId: string,
|
||||
userId: string,
|
||||
timestamp: Date,
|
||||
folderMapping: Map<string, string>
|
||||
): Promise<void> {
|
||||
// Get all child folders
|
||||
const childFolders = await tx
|
||||
.select()
|
||||
.from(workflowFolder)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowFolder.parentId, sourceFolderId),
|
||||
eq(workflowFolder.workspaceId, sourceWorkspaceId)
|
||||
)
|
||||
)
|
||||
|
||||
// Create each child folder and recurse
|
||||
for (const childFolder of childFolders) {
|
||||
const newChildFolderId = crypto.randomUUID()
|
||||
folderMapping.set(childFolder.id, newChildFolderId)
|
||||
|
||||
await tx.insert(workflowFolder).values({
|
||||
id: newChildFolderId,
|
||||
userId,
|
||||
workspaceId: targetWorkspaceId,
|
||||
name: childFolder.name,
|
||||
color: childFolder.color,
|
||||
parentId: newParentFolderId,
|
||||
sortOrder: childFolder.sortOrder,
|
||||
isExpanded: false,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
})
|
||||
|
||||
// Recurse for this child's children
|
||||
await duplicateFolderStructure(
|
||||
tx,
|
||||
childFolder.id,
|
||||
newChildFolderId,
|
||||
sourceWorkspaceId,
|
||||
targetWorkspaceId,
|
||||
userId,
|
||||
timestamp,
|
||||
folderMapping
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to duplicate all workflows in a folder tree
|
||||
async function duplicateWorkflowsInFolderTree(
|
||||
sourceWorkspaceId: string,
|
||||
targetWorkspaceId: string,
|
||||
folderMapping: Map<string, string>,
|
||||
userId: string,
|
||||
requestId: string
|
||||
): Promise<{ total: number; succeeded: number; failed: number }> {
|
||||
const stats = { total: 0, succeeded: 0, failed: 0 }
|
||||
|
||||
// Process each folder in the mapping
|
||||
for (const [oldFolderId, newFolderId] of folderMapping.entries()) {
|
||||
// Get workflows in this folder
|
||||
const workflowsInFolder = await db
|
||||
.select()
|
||||
.from(workflow)
|
||||
.where(and(eq(workflow.folderId, oldFolderId), eq(workflow.workspaceId, sourceWorkspaceId)))
|
||||
|
||||
stats.total += workflowsInFolder.length
|
||||
|
||||
// Duplicate each workflow
|
||||
for (const sourceWorkflow of workflowsInFolder) {
|
||||
try {
|
||||
await duplicateWorkflow({
|
||||
sourceWorkflowId: sourceWorkflow.id,
|
||||
userId,
|
||||
name: sourceWorkflow.name,
|
||||
description: sourceWorkflow.description || undefined,
|
||||
color: sourceWorkflow.color,
|
||||
workspaceId: targetWorkspaceId,
|
||||
folderId: newFolderId,
|
||||
requestId,
|
||||
})
|
||||
|
||||
stats.succeeded++
|
||||
} catch (error) {
|
||||
stats.failed++
|
||||
logger.error(`[${requestId}] Error duplicating workflow ${sourceWorkflow.id}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
@@ -1,14 +1,9 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflow, workflowBlocks, workflowEdges, workflowSubflows } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getUserEntityPermissions } from '@/lib/permissions/utils'
|
||||
import { generateRequestId } from '@/lib/utils'
|
||||
import type { Variable } from '@/stores/panel/variables/types'
|
||||
import type { LoopConfig, ParallelConfig } from '@/stores/workflows/workflow/types'
|
||||
import { duplicateWorkflow } from '@/lib/workflows/duplicate'
|
||||
|
||||
const logger = createLogger('WorkflowDuplicateAPI')
|
||||
|
||||
@@ -40,266 +35,20 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
`[${requestId}] Duplicating workflow ${sourceWorkflowId} for user ${session.user.id}`
|
||||
)
|
||||
|
||||
// Generate new workflow ID
|
||||
const newWorkflowId = crypto.randomUUID()
|
||||
const now = new Date()
|
||||
|
||||
// Duplicate workflow and all related data in a transaction
|
||||
const result = await db.transaction(async (tx) => {
|
||||
// First verify the source workflow exists
|
||||
const sourceWorkflowRow = await tx
|
||||
.select()
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, sourceWorkflowId))
|
||||
.limit(1)
|
||||
|
||||
if (sourceWorkflowRow.length === 0) {
|
||||
throw new Error('Source workflow not found')
|
||||
}
|
||||
|
||||
const source = sourceWorkflowRow[0]
|
||||
|
||||
// Check if user has permission to access the source workflow
|
||||
let canAccessSource = false
|
||||
|
||||
// Case 1: User owns the workflow
|
||||
if (source.userId === session.user.id) {
|
||||
canAccessSource = true
|
||||
}
|
||||
|
||||
// Case 2: User has admin or write permission in the source workspace
|
||||
if (!canAccessSource && source.workspaceId) {
|
||||
const userPermission = await getUserEntityPermissions(
|
||||
session.user.id,
|
||||
'workspace',
|
||||
source.workspaceId
|
||||
)
|
||||
if (userPermission === 'admin' || userPermission === 'write') {
|
||||
canAccessSource = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!canAccessSource) {
|
||||
throw new Error('Source workflow not found or access denied')
|
||||
}
|
||||
|
||||
// Create the new workflow first (required for foreign key constraints)
|
||||
await tx.insert(workflow).values({
|
||||
id: newWorkflowId,
|
||||
userId: session.user.id,
|
||||
workspaceId: workspaceId || source.workspaceId,
|
||||
folderId: folderId || source.folderId,
|
||||
name,
|
||||
description: description || source.description,
|
||||
color: color || source.color,
|
||||
lastSynced: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
isDeployed: false,
|
||||
runCount: 0,
|
||||
// Duplicate variables with new IDs and new workflowId
|
||||
variables: (() => {
|
||||
const sourceVars = (source.variables as Record<string, Variable>) || {}
|
||||
const remapped: Record<string, Variable> = {}
|
||||
for (const [, variable] of Object.entries(sourceVars) as [string, Variable][]) {
|
||||
const newVarId = crypto.randomUUID()
|
||||
remapped[newVarId] = {
|
||||
...variable,
|
||||
id: newVarId,
|
||||
workflowId: newWorkflowId,
|
||||
}
|
||||
}
|
||||
return remapped
|
||||
})(),
|
||||
})
|
||||
|
||||
// Copy all blocks from source workflow with new IDs
|
||||
const sourceBlocks = await tx
|
||||
.select()
|
||||
.from(workflowBlocks)
|
||||
.where(eq(workflowBlocks.workflowId, sourceWorkflowId))
|
||||
|
||||
// Create a mapping from old block IDs to new block IDs
|
||||
const blockIdMapping = new Map<string, string>()
|
||||
|
||||
if (sourceBlocks.length > 0) {
|
||||
// First pass: Create all block ID mappings
|
||||
sourceBlocks.forEach((block) => {
|
||||
const newBlockId = crypto.randomUUID()
|
||||
blockIdMapping.set(block.id, newBlockId)
|
||||
})
|
||||
|
||||
// Second pass: Create blocks with updated parent relationships
|
||||
const newBlocks = sourceBlocks.map((block) => {
|
||||
const newBlockId = blockIdMapping.get(block.id)!
|
||||
|
||||
// Update parent ID to point to the new parent block ID if it exists
|
||||
const blockData =
|
||||
block.data && typeof block.data === 'object' && !Array.isArray(block.data)
|
||||
? (block.data as any)
|
||||
: {}
|
||||
let newParentId = blockData.parentId
|
||||
if (blockData.parentId && blockIdMapping.has(blockData.parentId)) {
|
||||
newParentId = blockIdMapping.get(blockData.parentId)!
|
||||
}
|
||||
|
||||
// Update data.parentId and extent if they exist in the data object
|
||||
let updatedData = block.data
|
||||
let newExtent = blockData.extent
|
||||
if (block.data && typeof block.data === 'object' && !Array.isArray(block.data)) {
|
||||
const dataObj = block.data as any
|
||||
if (dataObj.parentId && typeof dataObj.parentId === 'string') {
|
||||
updatedData = { ...dataObj }
|
||||
if (blockIdMapping.has(dataObj.parentId)) {
|
||||
;(updatedData as any).parentId = blockIdMapping.get(dataObj.parentId)!
|
||||
// Ensure extent is set to 'parent' for child blocks
|
||||
;(updatedData as any).extent = 'parent'
|
||||
newExtent = 'parent'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...block,
|
||||
id: newBlockId,
|
||||
workflowId: newWorkflowId,
|
||||
parentId: newParentId,
|
||||
extent: newExtent,
|
||||
data: updatedData,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
})
|
||||
|
||||
await tx.insert(workflowBlocks).values(newBlocks)
|
||||
logger.info(
|
||||
`[${requestId}] Copied ${sourceBlocks.length} blocks with updated parent relationships`
|
||||
)
|
||||
}
|
||||
|
||||
// Copy all edges from source workflow with updated block references
|
||||
const sourceEdges = await tx
|
||||
.select()
|
||||
.from(workflowEdges)
|
||||
.where(eq(workflowEdges.workflowId, sourceWorkflowId))
|
||||
|
||||
if (sourceEdges.length > 0) {
|
||||
const newEdges = sourceEdges.map((edge) => ({
|
||||
...edge,
|
||||
id: crypto.randomUUID(), // Generate new edge ID
|
||||
workflowId: newWorkflowId,
|
||||
sourceBlockId: blockIdMapping.get(edge.sourceBlockId) || edge.sourceBlockId,
|
||||
targetBlockId: blockIdMapping.get(edge.targetBlockId) || edge.targetBlockId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}))
|
||||
|
||||
await tx.insert(workflowEdges).values(newEdges)
|
||||
logger.info(
|
||||
`[${requestId}] Copied ${sourceEdges.length} edges with updated block references`
|
||||
)
|
||||
}
|
||||
|
||||
// Copy all subflows from source workflow with new IDs and updated block references
|
||||
const sourceSubflows = await tx
|
||||
.select()
|
||||
.from(workflowSubflows)
|
||||
.where(eq(workflowSubflows.workflowId, sourceWorkflowId))
|
||||
|
||||
if (sourceSubflows.length > 0) {
|
||||
const newSubflows = sourceSubflows
|
||||
.map((subflow) => {
|
||||
// The subflow ID should match the corresponding block ID
|
||||
const newSubflowId = blockIdMapping.get(subflow.id)
|
||||
|
||||
if (!newSubflowId) {
|
||||
logger.warn(
|
||||
`[${requestId}] Subflow ${subflow.id} (${subflow.type}) has no corresponding block, skipping`
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Mapping subflow ${subflow.id} → ${newSubflowId}`, {
|
||||
subflowType: subflow.type,
|
||||
})
|
||||
|
||||
// Update block references in subflow config
|
||||
let updatedConfig: LoopConfig | ParallelConfig = subflow.config as
|
||||
| LoopConfig
|
||||
| ParallelConfig
|
||||
if (subflow.config && typeof subflow.config === 'object') {
|
||||
updatedConfig = JSON.parse(JSON.stringify(subflow.config)) as
|
||||
| LoopConfig
|
||||
| ParallelConfig
|
||||
|
||||
// Update the config ID to match the new subflow ID
|
||||
|
||||
;(updatedConfig as any).id = newSubflowId
|
||||
|
||||
// Update node references in config if they exist
|
||||
if ('nodes' in updatedConfig && Array.isArray(updatedConfig.nodes)) {
|
||||
updatedConfig.nodes = updatedConfig.nodes.map(
|
||||
(nodeId: string) => blockIdMapping.get(nodeId) || nodeId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...subflow,
|
||||
id: newSubflowId, // Use the same ID as the corresponding block
|
||||
workflowId: newWorkflowId,
|
||||
config: updatedConfig,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
})
|
||||
.filter((subflow): subflow is NonNullable<typeof subflow> => subflow !== null)
|
||||
|
||||
if (newSubflows.length > 0) {
|
||||
await tx.insert(workflowSubflows).values(newSubflows)
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Copied ${newSubflows.length}/${sourceSubflows.length} subflows with updated block references and matching IDs`,
|
||||
{
|
||||
subflowMappings: newSubflows.map((sf) => ({
|
||||
oldId: sourceSubflows.find((s) => blockIdMapping.get(s.id) === sf.id)?.id,
|
||||
newId: sf.id,
|
||||
type: sf.type,
|
||||
config: sf.config,
|
||||
})),
|
||||
blockIdMappings: Array.from(blockIdMapping.entries()).map(([oldId, newId]) => ({
|
||||
oldId,
|
||||
newId,
|
||||
})),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Update the workflow timestamp
|
||||
await tx
|
||||
.update(workflow)
|
||||
.set({
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(workflow.id, newWorkflowId))
|
||||
|
||||
return {
|
||||
id: newWorkflowId,
|
||||
name,
|
||||
description: description || source.description,
|
||||
color: color || source.color,
|
||||
workspaceId: workspaceId || source.workspaceId,
|
||||
folderId: folderId || source.folderId,
|
||||
blocksCount: sourceBlocks.length,
|
||||
edgesCount: sourceEdges.length,
|
||||
subflowsCount: sourceSubflows.length,
|
||||
}
|
||||
const result = await duplicateWorkflow({
|
||||
sourceWorkflowId,
|
||||
userId: session.user.id,
|
||||
name,
|
||||
description,
|
||||
color,
|
||||
workspaceId,
|
||||
folderId,
|
||||
requestId,
|
||||
})
|
||||
|
||||
const elapsed = Date.now() - startTime
|
||||
logger.info(
|
||||
`[${requestId}] Successfully duplicated workflow ${sourceWorkflowId} to ${newWorkflowId} in ${elapsed}ms`
|
||||
`[${requestId}] Successfully duplicated workflow ${sourceWorkflowId} to ${result.id} in ${elapsed}ms`
|
||||
)
|
||||
|
||||
return NextResponse.json(result, { status: 201 })
|
||||
|
||||
+24
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Pencil } from 'lucide-react'
|
||||
import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
|
||||
import { Trash } from '@/components/emcn/icons'
|
||||
import { Copy, Trash } from '@/components/emcn/icons'
|
||||
|
||||
interface ContextMenuProps {
|
||||
/**
|
||||
@@ -25,6 +25,10 @@ interface ContextMenuProps {
|
||||
* Callback when rename is clicked
|
||||
*/
|
||||
onRename: () => void
|
||||
/**
|
||||
* Callback when duplicate is clicked
|
||||
*/
|
||||
onDuplicate?: () => void
|
||||
/**
|
||||
* Callback when delete is clicked
|
||||
*/
|
||||
@@ -34,10 +38,15 @@ interface ContextMenuProps {
|
||||
* Set to false when multiple items are selected
|
||||
*/
|
||||
showRename?: boolean
|
||||
/**
|
||||
* Whether to show the duplicate option (default: true)
|
||||
* Set to false for items that cannot be duplicated (like folders)
|
||||
*/
|
||||
showDuplicate?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable context menu component for workflow and folder items.
|
||||
* Context menu component for workflow and folder items.
|
||||
* Displays rename and delete options in a popover at the right-click position.
|
||||
*
|
||||
* @param props - Component props
|
||||
@@ -49,8 +58,10 @@ export function ContextMenu({
|
||||
menuRef,
|
||||
onClose,
|
||||
onRename,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
showRename = true,
|
||||
showDuplicate = true,
|
||||
}: ContextMenuProps) {
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={onClose}>
|
||||
@@ -75,6 +86,17 @@ export function ContextMenu({
|
||||
<span>Rename</span>
|
||||
</PopoverItem>
|
||||
)}
|
||||
{showDuplicate && onDuplicate && (
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onDuplicate()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<Copy className='h-3 w-3' />
|
||||
<span>Duplicate</span>
|
||||
</PopoverItem>
|
||||
)}
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onDelete()
|
||||
|
||||
+24
-24
@@ -4,13 +4,16 @@ import { useCallback, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { ChevronRight, Folder, FolderOpen } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/context-menu/context-menu'
|
||||
import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/delete-modal/delete-modal'
|
||||
import {
|
||||
useContextMenu,
|
||||
useFolderExpand,
|
||||
useItemDrag,
|
||||
useItemRename,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
import { useDeleteFolder, useDuplicateFolder } from '@/app/workspace/[workspaceId]/w/hooks'
|
||||
import { type FolderTreeNode, useFolderStore } from '@/stores/folders/store'
|
||||
import { useContextMenu, useFolderExpand, useItemDrag, useItemRename } from '../../../../hooks'
|
||||
import { ContextMenu } from '../context-menu/context-menu'
|
||||
import { DeleteModal } from '../delete-modal/delete-modal'
|
||||
|
||||
const logger = createLogger('FolderItem')
|
||||
|
||||
interface FolderItemProps {
|
||||
folder: FolderTreeNode
|
||||
@@ -32,11 +35,23 @@ interface FolderItemProps {
|
||||
export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const { updateFolderAPI, deleteFolder } = useFolderStore()
|
||||
const { updateFolderAPI } = useFolderStore()
|
||||
|
||||
// Delete modal state
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
// Delete folder hook
|
||||
const { isDeleting, handleDeleteFolder } = useDeleteFolder({
|
||||
workspaceId,
|
||||
getFolderIds: () => folder.id,
|
||||
onSuccess: () => setIsDeleteModalOpen(false),
|
||||
})
|
||||
|
||||
// Duplicate folder hook
|
||||
const { handleDuplicateFolder } = useDuplicateFolder({
|
||||
workspaceId,
|
||||
getFolderIds: () => folder.id,
|
||||
})
|
||||
|
||||
// Folder expand hook
|
||||
const {
|
||||
@@ -99,22 +114,6 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
|
||||
itemId: folder.id,
|
||||
})
|
||||
|
||||
/**
|
||||
* Handle delete folder after confirmation
|
||||
*/
|
||||
const handleDeleteFolder = useCallback(async () => {
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
await deleteFolder(folder.id, workspaceId)
|
||||
setIsDeleteModalOpen(false)
|
||||
logger.info('Folder deleted successfully')
|
||||
} catch (error) {
|
||||
logger.error('Error deleting folder:', error)
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}, [folder.id, workspaceId, deleteFolder])
|
||||
|
||||
/**
|
||||
* Handle click - toggles folder expansion
|
||||
*
|
||||
@@ -220,6 +219,7 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
|
||||
menuRef={menuRef}
|
||||
onClose={closeMenu}
|
||||
onRename={handleStartEdit}
|
||||
onDuplicate={handleDuplicateFolder}
|
||||
onDelete={() => setIsDeleteModalOpen(true)}
|
||||
/>
|
||||
|
||||
|
||||
+18
-4
@@ -4,13 +4,17 @@ import { useCallback, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import Link from 'next/link'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useDeleteWorkflow } from '@/app/workspace/[workspaceId]/w/hooks'
|
||||
import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/context-menu/context-menu'
|
||||
import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/delete-modal/delete-modal'
|
||||
import {
|
||||
useContextMenu,
|
||||
useItemDrag,
|
||||
useItemRename,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
import { useDeleteWorkflow, useDuplicateWorkflow } from '@/app/workspace/[workspaceId]/w/hooks'
|
||||
import { useFolderStore } from '@/stores/folders/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
|
||||
import { useContextMenu, useItemDrag, useItemRename } from '../../../../hooks'
|
||||
import { ContextMenu } from '../context-menu/context-menu'
|
||||
import { DeleteModal } from '../delete-modal/delete-modal'
|
||||
|
||||
interface WorkflowItemProps {
|
||||
workflow: WorkflowMetadata
|
||||
@@ -64,6 +68,15 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
|
||||
onSuccess: () => setIsDeleteModalOpen(false),
|
||||
})
|
||||
|
||||
// Duplicate workflow hook
|
||||
const { handleDuplicateWorkflow } = useDuplicateWorkflow({
|
||||
workspaceId,
|
||||
getWorkflowIds: () => {
|
||||
// Use the selection captured at right-click time
|
||||
return capturedSelectionRef.current?.workflowIds || []
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Drag start handler - handles workflow dragging with multi-selection support
|
||||
*
|
||||
@@ -253,6 +266,7 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
|
||||
menuRef={menuRef}
|
||||
onClose={closeMenu}
|
||||
onRename={handleStartEdit}
|
||||
onDuplicate={handleDuplicateWorkflow}
|
||||
onDelete={handleOpenDeleteModal}
|
||||
showRename={selectedWorkflows.size <= 1}
|
||||
/>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
export { useAutoScroll } from './use-auto-scroll'
|
||||
export { useDeleteFolder } from './use-delete-folder'
|
||||
export { useDeleteWorkflow } from './use-delete-workflow'
|
||||
export { useDuplicateFolder } from './use-duplicate-folder'
|
||||
export { useDuplicateWorkflow } from './use-duplicate-workflow'
|
||||
export { useKeyboardShortcuts } from './use-keyboard-shortcuts'
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { useFolderStore } from '@/stores/folders/store'
|
||||
|
||||
const logger = createLogger('useDeleteFolder')
|
||||
|
||||
interface UseDeleteFolderProps {
|
||||
/**
|
||||
* Current workspace ID
|
||||
*/
|
||||
workspaceId: string
|
||||
/**
|
||||
* Function that returns the folder ID(s) to delete
|
||||
* This function is called when deletion occurs to get fresh selection state
|
||||
*/
|
||||
getFolderIds: () => string | string[]
|
||||
/**
|
||||
* Optional callback after successful deletion
|
||||
*/
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing folder deletion.
|
||||
*
|
||||
* Handles:
|
||||
* - Single or bulk folder deletion
|
||||
* - Calling delete API for each folder
|
||||
* - Loading state management
|
||||
* - Error handling and logging
|
||||
* - Clearing selection after deletion
|
||||
*
|
||||
* @param props - Hook configuration
|
||||
* @returns Delete folder handlers and state
|
||||
*/
|
||||
export function useDeleteFolder({ workspaceId, getFolderIds, onSuccess }: UseDeleteFolderProps) {
|
||||
const { deleteFolder } = useFolderStore()
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
/**
|
||||
* Delete the folder(s)
|
||||
*/
|
||||
const handleDeleteFolder = useCallback(async () => {
|
||||
if (isDeleting) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
// Get fresh folder IDs at deletion time
|
||||
const folderIdsOrId = getFolderIds()
|
||||
if (!folderIdsOrId) {
|
||||
return
|
||||
}
|
||||
|
||||
// Normalize to array for consistent handling
|
||||
const folderIdsToDelete = Array.isArray(folderIdsOrId) ? folderIdsOrId : [folderIdsOrId]
|
||||
|
||||
// Delete each folder sequentially
|
||||
for (const folderId of folderIdsToDelete) {
|
||||
await deleteFolder(folderId, workspaceId)
|
||||
}
|
||||
|
||||
// Clear selection after successful deletion
|
||||
const { clearSelection } = useFolderStore.getState()
|
||||
clearSelection()
|
||||
|
||||
logger.info('Folder(s) deleted successfully', { folderIds: folderIdsToDelete })
|
||||
onSuccess?.()
|
||||
} catch (error) {
|
||||
logger.error('Error deleting folder(s):', { error })
|
||||
throw error
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}, [getFolderIds, isDeleting, deleteFolder, workspaceId, onSuccess])
|
||||
|
||||
return {
|
||||
isDeleting,
|
||||
handleDeleteFolder,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { useFolderStore } from '@/stores/folders/store'
|
||||
|
||||
const logger = createLogger('useDuplicateFolder')
|
||||
|
||||
interface UseDuplicateFolderProps {
|
||||
/**
|
||||
* Current workspace ID
|
||||
*/
|
||||
workspaceId: string
|
||||
/**
|
||||
* Function that returns the folder ID(s) to duplicate
|
||||
* This function is called when duplication occurs to get fresh selection state
|
||||
*/
|
||||
getFolderIds: () => string | string[]
|
||||
/**
|
||||
* Optional callback after successful duplication
|
||||
*/
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing folder duplication.
|
||||
*
|
||||
* Handles:
|
||||
* - Single or bulk folder duplication
|
||||
* - Calling duplicate API for each folder
|
||||
* - Loading state management
|
||||
* - Error handling and logging
|
||||
* - Clearing selection after duplication
|
||||
*
|
||||
* @param props - Hook configuration
|
||||
* @returns Duplicate folder handlers and state
|
||||
*/
|
||||
export function useDuplicateFolder({
|
||||
workspaceId,
|
||||
getFolderIds,
|
||||
onSuccess,
|
||||
}: UseDuplicateFolderProps) {
|
||||
const { duplicateFolder } = useFolderStore()
|
||||
const [isDuplicating, setIsDuplicating] = useState(false)
|
||||
|
||||
/**
|
||||
* Duplicate the folder(s)
|
||||
*/
|
||||
const handleDuplicateFolder = useCallback(async () => {
|
||||
if (isDuplicating) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsDuplicating(true)
|
||||
try {
|
||||
// Get fresh folder IDs at duplication time
|
||||
const folderIdsOrId = getFolderIds()
|
||||
if (!folderIdsOrId) {
|
||||
return
|
||||
}
|
||||
|
||||
// Normalize to array for consistent handling
|
||||
const folderIdsToDuplicate = Array.isArray(folderIdsOrId) ? folderIdsOrId : [folderIdsOrId]
|
||||
|
||||
const duplicatedIds: string[] = []
|
||||
|
||||
// Duplicate each folder sequentially
|
||||
for (const folderId of folderIdsToDuplicate) {
|
||||
const newFolderId = await duplicateFolder(folderId)
|
||||
if (newFolderId) {
|
||||
duplicatedIds.push(newFolderId)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear selection after successful duplication
|
||||
const { clearSelection } = useFolderStore.getState()
|
||||
clearSelection()
|
||||
|
||||
logger.info('Folder(s) duplicated successfully', {
|
||||
folderIds: folderIdsToDuplicate,
|
||||
duplicatedIds,
|
||||
})
|
||||
|
||||
onSuccess?.()
|
||||
} catch (error) {
|
||||
logger.error('Error duplicating folder(s):', { error })
|
||||
throw error
|
||||
} finally {
|
||||
setIsDuplicating(false)
|
||||
}
|
||||
}, [getFolderIds, isDuplicating, duplicateFolder, onSuccess])
|
||||
|
||||
return {
|
||||
isDuplicating,
|
||||
handleDuplicateFolder,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
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('useDuplicateWorkflow')
|
||||
|
||||
interface UseDuplicateWorkflowProps {
|
||||
/**
|
||||
* Current workspace ID
|
||||
*/
|
||||
workspaceId: string
|
||||
/**
|
||||
* Function that returns the workflow ID(s) to duplicate
|
||||
* This function is called when duplication occurs to get fresh selection state
|
||||
*/
|
||||
getWorkflowIds: () => string | string[]
|
||||
/**
|
||||
* Optional callback after successful duplication
|
||||
*/
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing workflow duplication.
|
||||
*
|
||||
* Handles:
|
||||
* - Single or bulk workflow duplication
|
||||
* - Calling duplicate API for each workflow
|
||||
* - Loading state management
|
||||
* - Error handling and logging
|
||||
* - Clearing selection after duplication
|
||||
* - Navigation to duplicated workflow (single only)
|
||||
*
|
||||
* @param props - Hook configuration
|
||||
* @returns Duplicate workflow handlers and state
|
||||
*/
|
||||
export function useDuplicateWorkflow({
|
||||
workspaceId,
|
||||
getWorkflowIds,
|
||||
onSuccess,
|
||||
}: UseDuplicateWorkflowProps) {
|
||||
const router = useRouter()
|
||||
const { duplicateWorkflow } = useWorkflowRegistry()
|
||||
const [isDuplicating, setIsDuplicating] = useState(false)
|
||||
|
||||
/**
|
||||
* Duplicate the workflow(s)
|
||||
*/
|
||||
const handleDuplicateWorkflow = useCallback(async () => {
|
||||
if (isDuplicating) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsDuplicating(true)
|
||||
try {
|
||||
// Get fresh workflow IDs at duplication time
|
||||
const workflowIdsOrId = getWorkflowIds()
|
||||
if (!workflowIdsOrId) {
|
||||
return
|
||||
}
|
||||
|
||||
// Normalize to array for consistent handling
|
||||
const workflowIdsToDuplicate = Array.isArray(workflowIdsOrId)
|
||||
? workflowIdsOrId
|
||||
: [workflowIdsOrId]
|
||||
|
||||
const duplicatedIds: string[] = []
|
||||
|
||||
// Duplicate each workflow sequentially
|
||||
for (const workflowId of workflowIdsToDuplicate) {
|
||||
const newWorkflowId = await duplicateWorkflow(workflowId)
|
||||
if (newWorkflowId) {
|
||||
duplicatedIds.push(newWorkflowId)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear selection after successful duplication
|
||||
const { clearSelection } = useFolderStore.getState()
|
||||
clearSelection()
|
||||
|
||||
logger.info('Workflow(s) duplicated successfully', {
|
||||
workflowIds: workflowIdsToDuplicate,
|
||||
duplicatedIds,
|
||||
})
|
||||
|
||||
// Navigate to duplicated workflow if single duplication
|
||||
if (duplicatedIds.length === 1) {
|
||||
router.push(`/workspace/${workspaceId}/w/${duplicatedIds[0]}`)
|
||||
}
|
||||
|
||||
onSuccess?.()
|
||||
} catch (error) {
|
||||
logger.error('Error duplicating workflow(s):', { error })
|
||||
throw error
|
||||
} finally {
|
||||
setIsDuplicating(false)
|
||||
}
|
||||
}, [getWorkflowIds, isDuplicating, duplicateWorkflow, workspaceId, router, onSuccess])
|
||||
|
||||
return {
|
||||
isDuplicating,
|
||||
handleDuplicateWorkflow,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflow, workflowBlocks, workflowEdges, workflowSubflows } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getUserEntityPermissions } from '@/lib/permissions/utils'
|
||||
import type { Variable } from '@/stores/panel/variables/types'
|
||||
import type { LoopConfig, ParallelConfig } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('WorkflowDuplicateHelper')
|
||||
|
||||
interface DuplicateWorkflowOptions {
|
||||
sourceWorkflowId: string
|
||||
userId: string
|
||||
name: string
|
||||
description?: string
|
||||
color?: string
|
||||
workspaceId?: string
|
||||
folderId?: string | null
|
||||
requestId?: string
|
||||
}
|
||||
|
||||
interface DuplicateWorkflowResult {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
color: string
|
||||
workspaceId: string
|
||||
folderId: string | null
|
||||
blocksCount: number
|
||||
edgesCount: number
|
||||
subflowsCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate a workflow with all its blocks, edges, and subflows
|
||||
* This is a shared helper used by both the workflow duplicate API and folder duplicate API
|
||||
*/
|
||||
export async function duplicateWorkflow(
|
||||
options: DuplicateWorkflowOptions
|
||||
): Promise<DuplicateWorkflowResult> {
|
||||
const {
|
||||
sourceWorkflowId,
|
||||
userId,
|
||||
name,
|
||||
description,
|
||||
color,
|
||||
workspaceId,
|
||||
folderId,
|
||||
requestId = 'unknown',
|
||||
} = options
|
||||
|
||||
// Generate new workflow ID
|
||||
const newWorkflowId = crypto.randomUUID()
|
||||
const now = new Date()
|
||||
|
||||
// Duplicate workflow and all related data in a transaction
|
||||
const result = await db.transaction(async (tx) => {
|
||||
// First verify the source workflow exists
|
||||
const sourceWorkflowRow = await tx
|
||||
.select()
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, sourceWorkflowId))
|
||||
.limit(1)
|
||||
|
||||
if (sourceWorkflowRow.length === 0) {
|
||||
throw new Error('Source workflow not found')
|
||||
}
|
||||
|
||||
const source = sourceWorkflowRow[0]
|
||||
|
||||
// Check if user has permission to access the source workflow
|
||||
let canAccessSource = false
|
||||
|
||||
// Case 1: User owns the workflow
|
||||
if (source.userId === userId) {
|
||||
canAccessSource = true
|
||||
}
|
||||
|
||||
// Case 2: User has admin or write permission in the source workspace
|
||||
if (!canAccessSource && source.workspaceId) {
|
||||
const userPermission = await getUserEntityPermissions(userId, 'workspace', source.workspaceId)
|
||||
if (userPermission === 'admin' || userPermission === 'write') {
|
||||
canAccessSource = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!canAccessSource) {
|
||||
throw new Error('Source workflow not found or access denied')
|
||||
}
|
||||
|
||||
// Create the new workflow first (required for foreign key constraints)
|
||||
await tx.insert(workflow).values({
|
||||
id: newWorkflowId,
|
||||
userId,
|
||||
workspaceId: workspaceId || source.workspaceId,
|
||||
folderId: folderId !== undefined ? folderId : source.folderId,
|
||||
name,
|
||||
description: description || source.description,
|
||||
color: color || source.color,
|
||||
lastSynced: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
isDeployed: false,
|
||||
runCount: 0,
|
||||
// Duplicate variables with new IDs and new workflowId
|
||||
variables: (() => {
|
||||
const sourceVars = (source.variables as Record<string, Variable>) || {}
|
||||
const remapped: Record<string, Variable> = {}
|
||||
for (const [, variable] of Object.entries(sourceVars) as [string, Variable][]) {
|
||||
const newVarId = crypto.randomUUID()
|
||||
remapped[newVarId] = {
|
||||
...variable,
|
||||
id: newVarId,
|
||||
workflowId: newWorkflowId,
|
||||
}
|
||||
}
|
||||
return remapped
|
||||
})(),
|
||||
})
|
||||
|
||||
// Copy all blocks from source workflow with new IDs
|
||||
const sourceBlocks = await tx
|
||||
.select()
|
||||
.from(workflowBlocks)
|
||||
.where(eq(workflowBlocks.workflowId, sourceWorkflowId))
|
||||
|
||||
// Create a mapping from old block IDs to new block IDs
|
||||
const blockIdMapping = new Map<string, string>()
|
||||
|
||||
if (sourceBlocks.length > 0) {
|
||||
// First pass: Create all block ID mappings
|
||||
sourceBlocks.forEach((block) => {
|
||||
const newBlockId = crypto.randomUUID()
|
||||
blockIdMapping.set(block.id, newBlockId)
|
||||
})
|
||||
|
||||
// Second pass: Create blocks with updated parent relationships
|
||||
const newBlocks = sourceBlocks.map((block) => {
|
||||
const newBlockId = blockIdMapping.get(block.id)!
|
||||
|
||||
// Update parent ID to point to the new parent block ID if it exists
|
||||
const blockData =
|
||||
block.data && typeof block.data === 'object' && !Array.isArray(block.data)
|
||||
? (block.data as any)
|
||||
: {}
|
||||
let newParentId = blockData.parentId
|
||||
if (blockData.parentId && blockIdMapping.has(blockData.parentId)) {
|
||||
newParentId = blockIdMapping.get(blockData.parentId)!
|
||||
}
|
||||
|
||||
// Update data.parentId and extent if they exist in the data object
|
||||
let updatedData = block.data
|
||||
let newExtent = blockData.extent
|
||||
if (block.data && typeof block.data === 'object' && !Array.isArray(block.data)) {
|
||||
const dataObj = block.data as any
|
||||
if (dataObj.parentId && typeof dataObj.parentId === 'string') {
|
||||
updatedData = { ...dataObj }
|
||||
if (blockIdMapping.has(dataObj.parentId)) {
|
||||
;(updatedData as any).parentId = blockIdMapping.get(dataObj.parentId)!
|
||||
// Ensure extent is set to 'parent' for child blocks
|
||||
;(updatedData as any).extent = 'parent'
|
||||
newExtent = 'parent'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...block,
|
||||
id: newBlockId,
|
||||
workflowId: newWorkflowId,
|
||||
parentId: newParentId,
|
||||
extent: newExtent,
|
||||
data: updatedData,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
})
|
||||
|
||||
await tx.insert(workflowBlocks).values(newBlocks)
|
||||
logger.info(
|
||||
`[${requestId}] Copied ${sourceBlocks.length} blocks with updated parent relationships`
|
||||
)
|
||||
}
|
||||
|
||||
// Copy all edges from source workflow with updated block references
|
||||
const sourceEdges = await tx
|
||||
.select()
|
||||
.from(workflowEdges)
|
||||
.where(eq(workflowEdges.workflowId, sourceWorkflowId))
|
||||
|
||||
if (sourceEdges.length > 0) {
|
||||
const newEdges = sourceEdges.map((edge) => ({
|
||||
...edge,
|
||||
id: crypto.randomUUID(), // Generate new edge ID
|
||||
workflowId: newWorkflowId,
|
||||
sourceBlockId: blockIdMapping.get(edge.sourceBlockId) || edge.sourceBlockId,
|
||||
targetBlockId: blockIdMapping.get(edge.targetBlockId) || edge.targetBlockId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}))
|
||||
|
||||
await tx.insert(workflowEdges).values(newEdges)
|
||||
logger.info(`[${requestId}] Copied ${sourceEdges.length} edges with updated block references`)
|
||||
}
|
||||
|
||||
// Copy all subflows from source workflow with new IDs and updated block references
|
||||
const sourceSubflows = await tx
|
||||
.select()
|
||||
.from(workflowSubflows)
|
||||
.where(eq(workflowSubflows.workflowId, sourceWorkflowId))
|
||||
|
||||
if (sourceSubflows.length > 0) {
|
||||
const newSubflows = sourceSubflows
|
||||
.map((subflow) => {
|
||||
// The subflow ID should match the corresponding block ID
|
||||
const newSubflowId = blockIdMapping.get(subflow.id)
|
||||
|
||||
if (!newSubflowId) {
|
||||
logger.warn(
|
||||
`[${requestId}] Subflow ${subflow.id} (${subflow.type}) has no corresponding block, skipping`
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Mapping subflow ${subflow.id} → ${newSubflowId}`, {
|
||||
subflowType: subflow.type,
|
||||
})
|
||||
|
||||
// Update block references in subflow config
|
||||
let updatedConfig: LoopConfig | ParallelConfig = subflow.config as
|
||||
| LoopConfig
|
||||
| ParallelConfig
|
||||
if (subflow.config && typeof subflow.config === 'object') {
|
||||
updatedConfig = JSON.parse(JSON.stringify(subflow.config)) as
|
||||
| LoopConfig
|
||||
| ParallelConfig
|
||||
|
||||
// Update the config ID to match the new subflow ID
|
||||
|
||||
;(updatedConfig as any).id = newSubflowId
|
||||
|
||||
// Update node references in config if they exist
|
||||
if ('nodes' in updatedConfig && Array.isArray(updatedConfig.nodes)) {
|
||||
updatedConfig.nodes = updatedConfig.nodes.map(
|
||||
(nodeId: string) => blockIdMapping.get(nodeId) || nodeId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...subflow,
|
||||
id: newSubflowId, // Use the same ID as the corresponding block
|
||||
workflowId: newWorkflowId,
|
||||
config: updatedConfig,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
})
|
||||
.filter((subflow): subflow is NonNullable<typeof subflow> => subflow !== null)
|
||||
|
||||
if (newSubflows.length > 0) {
|
||||
await tx.insert(workflowSubflows).values(newSubflows)
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Copied ${newSubflows.length}/${sourceSubflows.length} subflows with updated block references and matching IDs`
|
||||
)
|
||||
}
|
||||
|
||||
// Update the workflow timestamp
|
||||
await tx
|
||||
.update(workflow)
|
||||
.set({
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(workflow.id, newWorkflowId))
|
||||
|
||||
const finalWorkspaceId = workspaceId || source.workspaceId
|
||||
if (!finalWorkspaceId) {
|
||||
throw new Error('Workspace ID is required')
|
||||
}
|
||||
|
||||
return {
|
||||
id: newWorkflowId,
|
||||
name,
|
||||
description: description || source.description,
|
||||
color: color || source.color,
|
||||
workspaceId: finalWorkspaceId,
|
||||
folderId: folderId !== undefined ? folderId : source.folderId,
|
||||
blocksCount: sourceBlocks.length,
|
||||
edgesCount: sourceEdges.length,
|
||||
subflowsCount: sourceSubflows.length,
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -74,6 +74,7 @@ interface FolderState {
|
||||
}) => Promise<WorkflowFolder>
|
||||
updateFolderAPI: (id: string, updates: Partial<WorkflowFolder>) => Promise<WorkflowFolder>
|
||||
deleteFolder: (id: string, workspaceId: string) => Promise<void>
|
||||
duplicateFolder: (id: string) => Promise<string | null>
|
||||
|
||||
// Helper functions
|
||||
isWorkflowInDeletedSubfolder: (workflow: Workflow, deletedFolderId: string) => boolean
|
||||
@@ -382,6 +383,50 @@ export const useFolderStore = create<FolderState>()(
|
||||
})
|
||||
},
|
||||
|
||||
duplicateFolder: async (id: string) => {
|
||||
const sourceFolder = get().folders[id]
|
||||
if (!sourceFolder) {
|
||||
logger.error(`Folder ${id} not found`)
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/folders/${id}/duplicate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: `${sourceFolder.name} (Copy)`,
|
||||
workspaceId: sourceFolder.workspaceId,
|
||||
parentId: sourceFolder.parentId,
|
||||
color: sourceFolder.color,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Failed to duplicate folder')
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
logger.info(
|
||||
`Successfully duplicated folder ${id} to ${result.id} with ${result.foldersCount} folder(s) and ${result.workflowsCount} workflow(s)`
|
||||
)
|
||||
|
||||
// Reload folders and workflows to reflect the duplication
|
||||
const workflowRegistry = useWorkflowRegistry.getState()
|
||||
await Promise.all([
|
||||
get().fetchFolders(sourceFolder.workspaceId),
|
||||
workflowRegistry.loadWorkflows(sourceFolder.workspaceId),
|
||||
])
|
||||
|
||||
return result.id
|
||||
} catch (error) {
|
||||
logger.error(`Failed to duplicate folder ${id}:`, error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
isWorkflowInDeletedSubfolder: (workflow: Workflow, deletedFolderId: string) => {
|
||||
if (!workflow.folderId) return false
|
||||
|
||||
|
||||
Reference in New Issue
Block a user