fix(workspace-popover): added duplicate, import, export workspace; added export multiple workflows (#1911)

* fix(workspace-popover): added duplicate, import, export workspace; added export multiple workflows

* fix copilot keyboard nav
This commit is contained in:
Waleed
2025-11-11 20:12:08 -08:00
committed by GitHub
parent 1d58fdf234
commit d11ee04432
23 changed files with 1711 additions and 303 deletions

View File

@@ -0,0 +1,79 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
import { generateRequestId } from '@/lib/utils'
import { duplicateWorkspace } from '@/lib/workspaces/duplicate'
const logger = createLogger('WorkspaceDuplicateAPI')
const DuplicateRequestSchema = z.object({
name: z.string().min(1, 'Name is required'),
})
// POST /api/workspaces/[id]/duplicate - Duplicate a workspace with all its workflows
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id: sourceWorkspaceId } = await params
const requestId = generateRequestId()
const startTime = Date.now()
const session = await getSession()
if (!session?.user?.id) {
logger.warn(
`[${requestId}] Unauthorized workspace duplication attempt for ${sourceWorkspaceId}`
)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const body = await req.json()
const { name } = DuplicateRequestSchema.parse(body)
logger.info(
`[${requestId}] Duplicating workspace ${sourceWorkspaceId} for user ${session.user.id}`
)
const result = await duplicateWorkspace({
sourceWorkspaceId,
userId: session.user.id,
name,
requestId,
})
const elapsed = Date.now() - startTime
logger.info(
`[${requestId}] Successfully duplicated workspace ${sourceWorkspaceId} to ${result.id} in ${elapsed}ms`
)
return NextResponse.json(result, { status: 201 })
} catch (error) {
if (error instanceof Error) {
if (error.message === 'Source workspace not found') {
logger.warn(`[${requestId}] Source workspace ${sourceWorkspaceId} not found`)
return NextResponse.json({ error: 'Source workspace not found' }, { status: 404 })
}
if (error.message === 'Source workspace not found or access denied') {
logger.warn(
`[${requestId}] User ${session.user.id} denied access to source workspace ${sourceWorkspaceId}`
)
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 workspace ${sourceWorkspaceId} after ${elapsed}ms:`,
error
)
return NextResponse.json({ error: 'Failed to duplicate workspace' }, { status: 500 })
}
}

View File

@@ -86,6 +86,8 @@ export function MentionMenu({
getActiveMentionQueryAtPosition,
getCaretPos,
submenuActiveIndex,
mentionActiveIndex,
openSubmenuFor,
} = mentionMenu
const {
@@ -282,6 +284,21 @@ export function MentionMenu({
// Show filtered aggregated view when there's a query
const showAggregatedView = currentQuery.length > 0
// Folder order for keyboard navigation - matches render order
const FOLDER_ORDER = [
'Chats', // 0
'Workflows', // 1
'Knowledge', // 2
'Blocks', // 3
'Workflow Blocks', // 4
'Templates', // 5
'Logs', // 6
'Docs', // 7
] as const
// Get active folder based on navigation when not in submenu and no query
const isInFolderNavigationMode = !openSubmenuFor && !showAggregatedView
// Compute caret viewport position via mirror technique for precise anchoring
const textareaEl = mentionMenu.textareaRef.current
if (!textareaEl) return null
@@ -372,7 +389,184 @@ export function MentionMenu({
>
<PopoverBackButton />
<PopoverScrollArea ref={menuListRef} className='space-y-[2px]'>
{showAggregatedView ? (
{openSubmenuFor ? (
// Submenu view - showing contents of a specific folder
<>
{openSubmenuFor === 'Chats' && (
<>
{mentionData.isLoadingPastChats ? (
<LoadingState />
) : mentionData.pastChats.length === 0 ? (
<EmptyState message='No past chats' />
) : (
mentionData.pastChats.map((chat, index) => (
<PopoverItem
key={chat.id}
onClick={() => insertPastChatMention(chat)}
data-idx={index}
active={index === submenuActiveIndex}
>
<span className='truncate'>{chat.title || 'New Chat'}</span>
</PopoverItem>
))
)}
</>
)}
{openSubmenuFor === 'Workflows' && (
<>
{mentionData.isLoadingWorkflows ? (
<LoadingState />
) : mentionData.workflows.length === 0 ? (
<EmptyState message='No workflows' />
) : (
mentionData.workflows.map((wf, index) => (
<PopoverItem
key={wf.id}
onClick={() => insertWorkflowMention(wf)}
data-idx={index}
active={index === submenuActiveIndex}
>
<div
className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
style={{ backgroundColor: wf.color || '#3972F6' }}
/>
<span className='truncate'>{wf.name || 'Untitled Workflow'}</span>
</PopoverItem>
))
)}
</>
)}
{openSubmenuFor === 'Knowledge' && (
<>
{mentionData.isLoadingKnowledge ? (
<LoadingState />
) : mentionData.knowledgeBases.length === 0 ? (
<EmptyState message='No knowledge bases' />
) : (
mentionData.knowledgeBases.map((kb, index) => (
<PopoverItem
key={kb.id}
onClick={() => insertKnowledgeMention(kb)}
data-idx={index}
active={index === submenuActiveIndex}
>
<span className='truncate'>{kb.name || 'Untitled'}</span>
</PopoverItem>
))
)}
</>
)}
{openSubmenuFor === 'Blocks' && (
<>
{mentionData.isLoadingBlocks ? (
<LoadingState />
) : mentionData.blocksList.length === 0 ? (
<EmptyState message='No blocks found' />
) : (
mentionData.blocksList.map((blk, index) => {
const Icon = blk.iconComponent
return (
<PopoverItem
key={blk.id}
onClick={() => insertBlockMention(blk)}
data-idx={index}
active={index === submenuActiveIndex}
>
<div
className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
style={{ backgroundColor: blk.bgColor || '#6B7280' }}
>
{Icon && <Icon className='!h-[10px] !w-[10px] text-white' />}
</div>
<span className='truncate'>{blk.name || blk.id}</span>
</PopoverItem>
)
})
)}
</>
)}
{openSubmenuFor === 'Workflow Blocks' && (
<>
{mentionData.isLoadingWorkflowBlocks ? (
<LoadingState />
) : mentionData.workflowBlocks.length === 0 ? (
<EmptyState message='No blocks in this workflow' />
) : (
mentionData.workflowBlocks.map((blk, index) => {
const Icon = blk.iconComponent
return (
<PopoverItem
key={blk.id}
onClick={() => insertWorkflowBlockMention(blk)}
data-idx={index}
active={index === submenuActiveIndex}
>
<div
className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
style={{ backgroundColor: blk.bgColor || '#6B7280' }}
>
{Icon && <Icon className='!h-[10px] !w-[10px] text-white' />}
</div>
<span className='truncate'>{blk.name || blk.id}</span>
</PopoverItem>
)
})
)}
</>
)}
{openSubmenuFor === 'Templates' && (
<>
{mentionData.isLoadingTemplates ? (
<LoadingState />
) : mentionData.templatesList.length === 0 ? (
<EmptyState message='No templates found' />
) : (
mentionData.templatesList.map((tpl, index) => (
<PopoverItem
key={tpl.id}
onClick={() => insertTemplateMention(tpl)}
data-idx={index}
active={index === submenuActiveIndex}
>
<span className='flex-1 truncate'>{tpl.name}</span>
<span className='text-[#868686] text-[10px] dark:text-[#868686]'>
{tpl.stars}
</span>
</PopoverItem>
))
)}
</>
)}
{openSubmenuFor === 'Logs' && (
<>
{mentionData.isLoadingLogs ? (
<LoadingState />
) : mentionData.logsList.length === 0 ? (
<EmptyState message='No executions found' />
) : (
mentionData.logsList.map((log, index) => (
<PopoverItem
key={log.id}
onClick={() => insertLogMention(log)}
data-idx={index}
active={index === submenuActiveIndex}
>
<span className='min-w-0 flex-1 truncate'>{log.workflowName}</span>
<span className='text-[#AEAEAE] text-[10px] dark:text-[#AEAEAE]'>·</span>
<span className='whitespace-nowrap text-[10px]'>
{formatTimestamp(log.createdAt)}
</span>
<span className='text-[#AEAEAE] text-[10px] dark:text-[#AEAEAE]'>·</span>
<span className='text-[10px] capitalize'>
{(log.trigger || 'manual').toLowerCase()}
</span>
</PopoverItem>
))
)}
</>
)}
</>
) : showAggregatedView ? (
// Aggregated filtered view
<>
{filteredAggregatedItems.length === 0 ? (
@@ -406,6 +600,8 @@ export function MentionMenu({
id='chats'
title='Chats'
onOpen={() => mentionData.ensurePastChatsLoaded()}
active={isInFolderNavigationMode && mentionActiveIndex === 0}
data-idx={0}
>
{mentionData.isLoadingPastChats ? (
<LoadingState />
@@ -424,6 +620,8 @@ export function MentionMenu({
id='workflows'
title='All workflows'
onOpen={() => mentionData.ensureWorkflowsLoaded()}
active={isInFolderNavigationMode && mentionActiveIndex === 1}
data-idx={1}
>
{mentionData.isLoadingWorkflows ? (
<LoadingState />
@@ -446,6 +644,8 @@ export function MentionMenu({
id='knowledge'
title='Knowledge Bases'
onOpen={() => mentionData.ensureKnowledgeLoaded()}
active={isInFolderNavigationMode && mentionActiveIndex === 2}
data-idx={2}
>
{mentionData.isLoadingKnowledge ? (
<LoadingState />
@@ -464,6 +664,8 @@ export function MentionMenu({
id='blocks'
title='Blocks'
onOpen={() => mentionData.ensureBlocksLoaded()}
active={isInFolderNavigationMode && mentionActiveIndex === 3}
data-idx={3}
>
{mentionData.isLoadingBlocks ? (
<LoadingState />
@@ -491,6 +693,8 @@ export function MentionMenu({
id='workflow-blocks'
title='Workflow Blocks'
onOpen={() => mentionData.ensureWorkflowBlocksLoaded()}
active={isInFolderNavigationMode && mentionActiveIndex === 4}
data-idx={4}
>
{mentionData.isLoadingWorkflowBlocks ? (
<LoadingState />
@@ -518,6 +722,8 @@ export function MentionMenu({
id='templates'
title='Templates'
onOpen={() => mentionData.ensureTemplatesLoaded()}
active={isInFolderNavigationMode && mentionActiveIndex === 5}
data-idx={5}
>
{mentionData.isLoadingTemplates ? (
<LoadingState />
@@ -535,7 +741,13 @@ export function MentionMenu({
)}
</PopoverFolder>
<PopoverFolder id='logs' title='Logs' onOpen={() => mentionData.ensureLogsLoaded()}>
<PopoverFolder
id='logs'
title='Logs'
onOpen={() => mentionData.ensureLogsLoaded()}
active={isInFolderNavigationMode && mentionActiveIndex === 6}
data-idx={6}
>
{mentionData.isLoadingLogs ? (
<LoadingState />
) : mentionData.logsList.length === 0 ? (
@@ -557,7 +769,12 @@ export function MentionMenu({
)}
</PopoverFolder>
<PopoverItem rootOnly onClick={() => insertDocsMention()}>
<PopoverItem
rootOnly
onClick={() => insertDocsMention()}
active={isInFolderNavigationMode && mentionActiveIndex === 7}
data-idx={7}
>
<span>Docs</span>
</PopoverItem>
</>

View File

@@ -3,17 +3,17 @@
*/
/**
* Mention menu options in order
* Mention menu options in order (matches visual render order)
*/
export const MENTION_OPTIONS = [
'Chats',
'Workflows',
'Workflow Blocks',
'Blocks',
'Knowledge',
'Docs',
'Blocks',
'Workflow Blocks',
'Templates',
'Logs',
'Docs',
] as const
/**

View File

@@ -2,6 +2,7 @@
import { useCallback, useEffect, useState } from 'react'
import { createLogger } from '@/lib/logs/console/logger'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const logger = createLogger('useMentionData')
@@ -93,9 +94,6 @@ export function useMentionData(props: UseMentionDataProps) {
const [pastChats, setPastChats] = useState<PastChat[]>([])
const [isLoadingPastChats, setIsLoadingPastChats] = useState(false)
const [workflows, setWorkflows] = useState<WorkflowItem[]>([])
const [isLoadingWorkflows, setIsLoadingWorkflows] = useState(false)
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeItem[]>([])
const [isLoadingKnowledge, setIsLoadingKnowledge] = useState(false)
@@ -113,6 +111,24 @@ export function useMentionData(props: UseMentionDataProps) {
const workflowStoreBlocks = useWorkflowStore((state) => state.blocks)
// Use workflow registry as source of truth for workflows
const registryWorkflows = useWorkflowRegistry((state) => state.workflows)
const isLoadingWorkflows = useWorkflowRegistry((state) => state.isLoading)
// Convert registry workflows to mention format, filtered by workspace and sorted
const workflows: WorkflowItem[] = Object.values(registryWorkflows)
.filter((w) => w.workspaceId === workspaceId)
.sort((a, b) => {
const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0
const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0
return dateB - dateA
})
.map((w) => ({
id: w.id,
name: w.name || 'Untitled Workflow',
color: w.color,
}))
/**
* Resets past chats when workflow changes
*/
@@ -121,15 +137,6 @@ export function useMentionData(props: UseMentionDataProps) {
setIsLoadingPastChats(false)
}, [workflowId])
/**
* Loads workflows on mount if needed
*/
useEffect(() => {
if (workflowId && workflows.length === 0) {
ensureWorkflowsLoaded()
}
}, [workflowId])
/**
* Syncs workflow blocks from store
*/
@@ -193,36 +200,12 @@ export function useMentionData(props: UseMentionDataProps) {
}, [isLoadingPastChats, pastChats.length, workflowId])
/**
* Ensures workflows are loaded
* Ensures workflows are loaded (now using registry store)
*/
const ensureWorkflowsLoaded = useCallback(async () => {
if (isLoadingWorkflows || workflows.length > 0) return
try {
setIsLoadingWorkflows(true)
const resp = await fetch('/api/workflows')
if (!resp.ok) throw new Error(`Failed to load workflows: ${resp.status}`)
const data = await resp.json()
const items = Array.isArray(data?.data) ? data.data : []
const workspaceFiltered = items.filter(
(w: any) => w.workspaceId === workspaceId || !w.workspaceId
)
const sorted = [...workspaceFiltered].sort((a: any, b: any) => {
const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0
const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0
return dateB - dateA
})
setWorkflows(
sorted.map((w: any) => ({
id: w.id,
name: w.name || 'Untitled Workflow',
color: w.color,
}))
)
} catch {
} finally {
setIsLoadingWorkflows(false)
}
}, [isLoadingWorkflows, workflows.length, workspaceId])
const ensureWorkflowsLoaded = useCallback(() => {
// Workflows are now automatically loaded from the registry store
// No manual fetching needed
}, [])
/**
* Ensures knowledge bases are loaded

View File

@@ -87,10 +87,8 @@ export function useMentionKeyboard({
openSubmenuFor,
mentionActiveIndex,
submenuActiveIndex,
inAggregated,
setMentionActiveIndex,
setSubmenuActiveIndex,
setInAggregated,
setOpenSubmenuFor,
setSubmenuQueryStart,
getCaretPos,

View File

@@ -1,6 +1,6 @@
'use client'
import { Pencil } from 'lucide-react'
import { ArrowUp, Pencil, Plus } from 'lucide-react'
import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
import { Copy, Trash } from '@/components/emcn/icons'
@@ -24,11 +24,19 @@ interface ContextMenuProps {
/**
* Callback when rename is clicked
*/
onRename: () => void
onRename?: () => void
/**
* Callback when create is clicked (for folders)
*/
onCreate?: () => void
/**
* Callback when duplicate is clicked
*/
onDuplicate?: () => void
/**
* Callback when export is clicked
*/
onExport?: () => void
/**
* Callback when delete is clicked
*/
@@ -38,16 +46,26 @@ interface ContextMenuProps {
* Set to false when multiple items are selected
*/
showRename?: boolean
/**
* Whether to show the create option (default: false)
* Set to true for folders to create workflows inside
*/
showCreate?: boolean
/**
* Whether to show the duplicate option (default: true)
* Set to false for items that cannot be duplicated (like folders)
* Set to false for items that cannot be duplicated
*/
showDuplicate?: boolean
/**
* Whether to show the export option (default: false)
* Set to true for items that can be exported (like workspaces)
*/
showExport?: boolean
}
/**
* Context menu component for workflow and folder items.
* Displays rename and delete options in a popover at the right-click position.
* Context menu component for workflow, folder, and workspace items.
* Displays context-appropriate options (rename, duplicate, export, delete) in a popover at the right-click position.
*
* @param props - Component props
* @returns Context menu popover
@@ -58,10 +76,14 @@ export function ContextMenu({
menuRef,
onClose,
onRename,
onCreate,
onDuplicate,
onExport,
onDelete,
showRename = true,
showCreate = false,
showDuplicate = true,
showExport = false,
}: ContextMenuProps) {
return (
<Popover open={isOpen} onOpenChange={onClose}>
@@ -75,7 +97,7 @@ export function ContextMenu({
}}
/>
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
{showRename && (
{showRename && onRename && (
<PopoverItem
onClick={() => {
onRename()
@@ -86,6 +108,17 @@ export function ContextMenu({
<span>Rename</span>
</PopoverItem>
)}
{showCreate && onCreate && (
<PopoverItem
onClick={() => {
onCreate()
onClose()
}}
>
<Plus className='h-3 w-3' />
<span>Create workflow</span>
</PopoverItem>
)}
{showDuplicate && onDuplicate && (
<PopoverItem
onClick={() => {
@@ -97,6 +130,17 @@ export function ContextMenu({
<span>Duplicate</span>
</PopoverItem>
)}
{showExport && onExport && (
<PopoverItem
onClick={() => {
onExport()
onClose()
}}
>
<ArrowUp className='h-3 w-3' />
<span>Export</span>
</PopoverItem>
)}
<PopoverItem
onClick={() => {
onDelete()

View File

@@ -3,7 +3,7 @@
import { useCallback, useState } from 'react'
import clsx from 'clsx'
import { ChevronRight, Folder, FolderOpen } from 'lucide-react'
import { useParams } from 'next/navigation'
import { useParams, useRouter } from 'next/navigation'
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 {
@@ -14,6 +14,7 @@ import {
} 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 { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface FolderItemProps {
folder: FolderTreeNode
@@ -34,8 +35,10 @@ interface FolderItemProps {
*/
export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
const params = useParams()
const router = useRouter()
const workspaceId = params.workspaceId as string
const { updateFolderAPI } = useFolderStore()
const { createWorkflow } = useWorkflowRegistry()
// Delete modal state
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
@@ -53,6 +56,20 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
getFolderIds: () => folder.id,
})
/**
* Handle create workflow in folder
*/
const handleCreateWorkflowInFolder = useCallback(async () => {
const workflowId = await createWorkflow({
workspaceId,
folderId: folder.id,
})
if (workflowId) {
router.push(`/workspace/${workspaceId}/w/${workflowId}`)
}
}, [createWorkflow, workspaceId, folder.id, router])
// Folder expand hook
const {
isExpanded,
@@ -219,8 +236,10 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
menuRef={menuRef}
onClose={closeMenu}
onRename={handleStartEdit}
onCreate={handleCreateWorkflowInFolder}
onDuplicate={handleDuplicateFolder}
onDelete={() => setIsDeleteModalOpen(true)}
showCreate={true}
/>
{/* Delete Modal */}

View File

@@ -12,7 +12,11 @@ import {
useItemDrag,
useItemRename,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { useDeleteWorkflow, useDuplicateWorkflow } from '@/app/workspace/[workspaceId]/w/hooks'
import {
useDeleteWorkflow,
useDuplicateWorkflow,
useExportWorkflow,
} 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'
@@ -81,6 +85,15 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
},
})
// Export workflow hook
const { handleExportWorkflow } = useExportWorkflow({
workspaceId,
getWorkflowIds: () => {
// Use the selection captured at right-click time
return capturedSelectionRef.current?.workflowIds || []
},
})
/**
* Drag start handler - handles workflow dragging with multi-selection support
*
@@ -276,8 +289,11 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
onClose={closeMenu}
onRename={handleStartEdit}
onDuplicate={handleDuplicateWorkflow}
onExport={handleExportWorkflow}
onDelete={handleOpenDeleteModal}
showRename={selectedWorkflows.size <= 1}
showDuplicate={true}
showExport={true}
/>
{/* Delete Confirmation Modal */}

View File

@@ -3,11 +3,15 @@
import { useCallback, useEffect, useMemo } from 'react'
import clsx from 'clsx'
import { useParams, usePathname } from 'next/navigation'
import { FolderItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/folder-item/folder-item'
import { WorkflowItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/workflow-item/workflow-item'
import {
useDragDrop,
useWorkflowSelection,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks/use-import-workflow'
import { type FolderTreeNode, useFolderStore } from '@/stores/folders/store'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
import { useDragDrop, useWorkflowImport, useWorkflowSelection } from '../../hooks'
import { FolderItem } from './components/folder-item/folder-item'
import { WorkflowItem } from './components/workflow-item/workflow-item'
/**
* Constants for tree layout and styling
@@ -72,7 +76,7 @@ export function WorkflowList({
} = useDragDrop()
// Workflow import hook
const { handleFileChange } = useWorkflowImport({ workspaceId })
const { handleFileChange } = useImportWorkflow({ workspaceId })
// Set scroll container when ref changes
useEffect(() => {
@@ -366,7 +370,8 @@ export function WorkflowList({
<input
ref={fileInputRef}
type='file'
accept='.json'
accept='.json,.zip'
multiple
style={{ display: 'none' }}
onChange={handleFileChange}
/>

View File

@@ -1,7 +1,7 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { Pencil, Plus, RefreshCw, Settings } from 'lucide-react'
import { ArrowDown, Plus, RefreshCw } from 'lucide-react'
import {
Badge,
Button,
@@ -13,10 +13,10 @@ import {
PopoverSection,
PopoverTrigger,
Tooltip,
Trash,
} from '@/components/emcn'
import { createLogger } from '@/lib/logs/console/logger'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { ContextMenu } from '../workflow-list/components/context-menu/context-menu'
import { DeleteModal } from '../workflow-list/components/delete-modal/delete-modal'
import { InviteModal } from './components'
@@ -82,6 +82,22 @@ interface WorkspaceHeaderProps {
* Callback to delete the workspace
*/
onDeleteWorkspace: (workspaceId: string) => Promise<void>
/**
* Callback to duplicate the workspace
*/
onDuplicateWorkspace: (workspaceId: string, workspaceName: string) => Promise<void>
/**
* Callback to export the workspace
*/
onExportWorkspace: (workspaceId: string, workspaceName: string) => Promise<void>
/**
* Callback to import workspace
*/
onImportWorkspace: () => void
/**
* Whether workspace import is in progress
*/
isImportingWorkspace: boolean
}
/**
@@ -102,18 +118,27 @@ export function WorkspaceHeader({
isCollapsed,
onRenameWorkspace,
onDeleteWorkspace,
onDuplicateWorkspace,
onExportWorkspace,
onImportWorkspace,
isImportingWorkspace,
}: WorkspaceHeaderProps) {
const userPermissions = useUserPermissionsContext()
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false)
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<Workspace | null>(null)
const [settingsWorkspaceId, setSettingsWorkspaceId] = useState<string | null>(null)
const [editingWorkspaceId, setEditingWorkspaceId] = useState<string | null>(null)
const [editingName, setEditingName] = useState('')
const [isListRenaming, setIsListRenaming] = useState(false)
const listRenameInputRef = useRef<HTMLInputElement | null>(null)
// Context menu state
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 })
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
const contextMenuRef = useRef<HTMLDivElement | null>(null)
const capturedWorkspaceRef = useRef<{ id: string; name: string } | null>(null)
/**
* Focus the inline list rename input when it becomes active
*/
@@ -151,21 +176,65 @@ export function WorkspaceHeader({
}
/**
* Handles rename action from settings menu
* Handle right-click context menu
*/
const handleRenameAction = (workspace: Workspace) => {
setSettingsWorkspaceId(null)
setEditingWorkspaceId(workspace.id)
setEditingName(workspace.name)
const handleContextMenu = (e: React.MouseEvent, workspace: Workspace) => {
e.preventDefault()
e.stopPropagation()
capturedWorkspaceRef.current = { id: workspace.id, name: workspace.name }
setContextMenuPosition({ x: e.clientX, y: e.clientY })
setIsContextMenuOpen(true)
}
/**
* Handles delete action from settings menu
* Close context menu
*/
const handleDeleteAction = (workspace: Workspace) => {
setSettingsWorkspaceId(null)
setDeleteTarget(workspace)
setIsDeleteModalOpen(true)
const closeContextMenu = () => {
setIsContextMenuOpen(false)
}
/**
* Handles rename action from context menu
*/
const handleRenameAction = () => {
if (!capturedWorkspaceRef.current) return
setEditingWorkspaceId(capturedWorkspaceRef.current.id)
setEditingName(capturedWorkspaceRef.current.name)
}
/**
* Handles duplicate action from context menu
*/
const handleDuplicateAction = async () => {
if (!capturedWorkspaceRef.current) return
await onDuplicateWorkspace(capturedWorkspaceRef.current.id, capturedWorkspaceRef.current.name)
setIsWorkspaceMenuOpen(false)
}
/**
* Handles export action from context menu
*/
const handleExportAction = async () => {
if (!capturedWorkspaceRef.current) return
await onExportWorkspace(capturedWorkspaceRef.current.id, capturedWorkspaceRef.current.name)
}
/**
* Handles delete action from context menu
*/
const handleDeleteAction = () => {
if (!capturedWorkspaceRef.current) return
const workspace = workspaces.find((w) => w.id === capturedWorkspaceRef.current?.id)
if (workspace) {
setDeleteTarget(workspace)
setIsDeleteModalOpen(true)
setIsWorkspaceMenuOpen(false)
}
}
/**
@@ -220,7 +289,16 @@ export function WorkspaceHeader({
Invite
</Badge>
{/* Workspace Switcher Popover */}
<Popover open={isWorkspaceMenuOpen} onOpenChange={setIsWorkspaceMenuOpen}>
<Popover
open={isWorkspaceMenuOpen}
onOpenChange={(open) => {
// Don't close if context menu is opening
if (!open && isContextMenuOpen) {
return
}
setIsWorkspaceMenuOpen(open)
}}
>
<PopoverTrigger asChild>
<Button
variant='ghost-secondary'
@@ -240,6 +318,7 @@ export function WorkspaceHeader({
side='bottom'
sideOffset={8}
style={{ maxWidth: '160px', minWidth: '160px' }}
onOpenAutoFocus={(e) => e.preventDefault()}
>
{isWorkspacesLoading ? (
<PopoverItem disabled>
@@ -249,20 +328,51 @@ export function WorkspaceHeader({
<>
<div className='relative flex items-center justify-between'>
<PopoverSection>Workspaces</PopoverSection>
<Button
variant='ghost'
type='button'
aria-label='Create workspace'
className='!p-[3px] absolute top-[3px] right-[5.5px]'
onClick={async (e) => {
e.stopPropagation()
await onCreateWorkspace()
setIsWorkspaceMenuOpen(false)
}}
disabled={isCreatingWorkspace}
>
<Plus className='h-[14px] w-[14px]' />
</Button>
<div className='flex items-center gap-[6px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
type='button'
aria-label='Import workspace'
className='!p-[3px]'
onClick={(e) => {
e.stopPropagation()
onImportWorkspace()
}}
disabled={isImportingWorkspace}
>
<ArrowDown className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content className='py-[2.5px]'>
<p>
{isImportingWorkspace ? 'Importing workspace...' : 'Import workspace'}
</p>
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
type='button'
aria-label='Create workspace'
className='!p-[3px]'
onClick={async (e) => {
e.stopPropagation()
await onCreateWorkspace()
setIsWorkspaceMenuOpen(false)
}}
disabled={isCreatingWorkspace}
>
<Plus className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content className='py-[2.5px]'>
<p>{isCreatingWorkspace ? 'Creating workspace...' : 'Create workspace'}</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
</div>
<div className='max-h-[200px] overflow-y-auto'>
{workspaces.map((workspace, index) => (
@@ -312,48 +422,13 @@ export function WorkspaceHeader({
/>
</div>
) : (
<div className='group relative flex items-center'>
<PopoverItem
active={workspace.id === workspaceId}
onClick={() => onWorkspaceSwitch(workspace)}
className='flex-1 pr-[28px]'
>
<span className='min-w-0 flex-1 truncate'>{workspace.name}</span>
</PopoverItem>
<Popover
open={settingsWorkspaceId === workspace.id}
onOpenChange={(open) =>
setSettingsWorkspaceId(open ? workspace.id : null)
}
>
<PopoverTrigger asChild>
<Button
variant='ghost'
type='button'
aria-label='Workspace settings'
className='!p-[4px] absolute right-[4px]'
onClick={(e) => {
e.stopPropagation()
}}
>
<Settings className='h-[14px] w-[14px]' />
</Button>
</PopoverTrigger>
<PopoverContent align='end' side='right' sideOffset={10}>
<PopoverItem onClick={() => handleRenameAction(workspace)}>
<Pencil className='h-3 w-3' />
<span>Rename</span>
</PopoverItem>
<PopoverItem
onClick={() => handleDeleteAction(workspace)}
className='mt-[2px]'
>
<Trash className='h-3 w-3' />
<span>Delete</span>
</PopoverItem>
</PopoverContent>
</Popover>
</div>
<PopoverItem
active={workspace.id === workspaceId}
onClick={() => onWorkspaceSwitch(workspace)}
onContextMenu={(e) => handleContextMenu(e, workspace)}
>
<span className='min-w-0 flex-1 truncate'>{workspace.name}</span>
</PopoverItem>
)}
</div>
))}
@@ -373,6 +448,22 @@ export function WorkspaceHeader({
<PanelLeft className='h-[17.5px] w-[17.5px]' />
</Button>
</div>
{/* Context Menu */}
<ContextMenu
isOpen={isContextMenuOpen}
position={contextMenuPosition}
menuRef={contextMenuRef}
onClose={closeContextMenu}
onRename={handleRenameAction}
onDuplicate={handleDuplicateAction}
onExport={handleExportAction}
onDelete={handleDeleteAction}
showRename={true}
showDuplicate={true}
showExport={true}
/>
{/* Invite Modal */}
<InviteModal
open={isInviteModalOpen}

View File

@@ -1,3 +1,4 @@
export { useAutoScroll } from './use-auto-scroll'
export { useContextMenu } from './use-context-menu'
export { useDragDrop } from './use-drag-drop'
export { useFolderExpand } from './use-folder-expand'
@@ -5,7 +6,6 @@ export { useFolderOperations } from './use-folder-operations'
export { useItemDrag } from './use-item-drag'
export { useItemRename } from './use-item-rename'
export { useSidebarResize } from './use-sidebar-resize'
export { useWorkflowImport } from './use-workflow-import'
export { useWorkflowOperations } from './use-workflow-operations'
export { useWorkflowSelection } from './use-workflow-selection'
export { useWorkspaceManagement } from './use-workspace-management'

View File

@@ -1,166 +0,0 @@
import { useCallback, useState } from 'react'
import { useRouter } from 'next/navigation'
import { createLogger } from '@/lib/logs/console/logger'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import { parseWorkflowJson } from '@/stores/workflows/json/importer'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('useWorkflowImport')
interface UseWorkflowImportProps {
workspaceId: string
}
/**
* Custom hook to handle workflow JSON import functionality.
* Manages file reading, JSON parsing, workflow creation, and state initialization.
*
* @param props - Configuration object containing workspaceId
* @returns Import state and handlers
*/
export function useWorkflowImport({ workspaceId }: UseWorkflowImportProps) {
const router = useRouter()
const { createWorkflow } = useWorkflowRegistry()
const [isImporting, setIsImporting] = useState(false)
/**
* Handle direct import of workflow JSON
*/
const handleDirectImport = useCallback(
async (content: string, filename?: string) => {
if (!content.trim()) {
logger.error('JSON content is required')
return
}
setIsImporting(true)
try {
// First validate the JSON without importing
const { data: workflowData, errors: parseErrors } = parseWorkflowJson(content)
if (!workflowData || parseErrors.length > 0) {
logger.error('Failed to parse JSON:', { errors: parseErrors })
return
}
// Generate workflow name from filename or fallback to time-based name
const getWorkflowName = () => {
if (filename) {
// Remove file extension and use the filename
const nameWithoutExtension = filename.replace(/\.json$/i, '')
return (
nameWithoutExtension.trim() || `Imported Workflow - ${new Date().toLocaleString()}`
)
}
return `Imported Workflow - ${new Date().toLocaleString()}`
}
// Clear workflow diff store when creating a new workflow from import
const { clearDiff } = useWorkflowDiffStore.getState()
clearDiff()
// Create a new workflow
const newWorkflowId = await createWorkflow({
name: getWorkflowName(),
description: 'Workflow imported from JSON',
workspaceId,
})
// Set the workflow as active in the registry to prevent reload
useWorkflowRegistry.setState({ activeWorkflowId: newWorkflowId })
// Cast the workflow data to WorkflowState type
const typedWorkflowData = workflowData as unknown as WorkflowState
// Set the workflow state immediately (optimistic update)
useWorkflowStore.setState({
blocks: typedWorkflowData.blocks,
edges: typedWorkflowData.edges,
loops: typedWorkflowData.loops,
parallels: typedWorkflowData.parallels,
lastSaved: Date.now(),
})
// Initialize subblock store with the imported blocks
useSubBlockStore.getState().initializeFromWorkflow(newWorkflowId, typedWorkflowData.blocks)
// Set subblock values if they exist in the imported data
const subBlockStore = useSubBlockStore.getState()
for (const [blockId, block] of Object.entries(typedWorkflowData.blocks)) {
if (block.subBlocks) {
for (const [subBlockId, subBlock] of Object.entries(block.subBlocks)) {
if (subBlock.value !== null && subBlock.value !== undefined) {
subBlockStore.setValue(blockId, subBlockId, subBlock.value)
}
}
}
}
// Navigate to the new workflow after setting state
router.push(`/workspace/${workspaceId}/w/${newWorkflowId}`)
logger.info('Workflow imported successfully from JSON')
// Persist to database in the background
fetch(`/api/workflows/${newWorkflowId}/state`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(workflowData),
})
.then((response) => {
if (!response.ok) {
logger.error('Failed to persist imported workflow to database')
} else {
logger.info('Imported workflow persisted to database')
}
})
.catch((error) => {
logger.error('Failed to persist imported workflow:', error)
})
} catch (error) {
logger.error('Failed to import workflow:', { error })
} finally {
setIsImporting(false)
}
},
[createWorkflow, workspaceId, router]
)
/**
* Handle file selection and read
*/
const handleFileChange = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
try {
const content = await file.text()
// Import directly with filename
await handleDirectImport(content, file.name)
} catch (error) {
logger.error('Failed to read file:', { error })
}
// Reset file input
const input = event.target
if (input) {
input.value = ''
}
},
[handleDirectImport]
)
return {
isImporting,
handleDirectImport,
handleFileChange,
}
}

View File

@@ -19,6 +19,11 @@ import {
useWorkflowOperations,
useWorkspaceManagement,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import {
useDuplicateWorkspace,
useExportWorkspace,
useImportWorkspace,
} from '@/app/workspace/[workspaceId]/w/hooks'
import { useFolderStore } from '@/stores/folders/store'
import { useSidebarStore } from '@/stores/sidebar/store'
@@ -56,6 +61,17 @@ export function SidebarNew() {
// Import state
const [isImporting, setIsImporting] = useState(false)
// Workspace import input ref
const workspaceFileInputRef = useRef<HTMLInputElement>(null)
// Workspace import hook
const { isImporting: isImportingWorkspace, handleImportWorkspace: importWorkspace } =
useImportWorkspace()
// Workspace export hook
const { isExporting: isExportingWorkspace, handleExportWorkspace: exportWorkspace } =
useExportWorkspace()
// Workspace popover state
const [isWorkspaceMenuOpen, setIsWorkspaceMenuOpen] = useState(false)
@@ -99,6 +115,11 @@ export function SidebarNew() {
workspaceId,
})
// Duplicate workspace hook
const { handleDuplicateWorkspace: duplicateWorkspace } = useDuplicateWorkspace({
getWorkspaceId: () => workspaceId,
})
// Prepare data for search modal
const searchModalWorkflows = useMemo(
() =>
@@ -279,6 +300,54 @@ export function SidebarNew() {
[workspaces, confirmDeleteWorkspace]
)
/**
* Handle workspace duplicate
*/
const handleDuplicateWorkspace = useCallback(
async (_workspaceIdToDuplicate: string, workspaceName: string) => {
await duplicateWorkspace(workspaceName)
},
[duplicateWorkspace]
)
/**
* Handle workspace export
*/
const handleExportWorkspace = useCallback(
async (workspaceIdToExport: string, workspaceName: string) => {
await exportWorkspace(workspaceIdToExport, workspaceName)
},
[exportWorkspace]
)
/**
* Handle workspace import button click
*/
const handleImportWorkspace = useCallback(() => {
if (workspaceFileInputRef.current) {
workspaceFileInputRef.current.click()
}
}, [])
/**
* Handle workspace import file change
*/
const handleWorkspaceFileChange = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files
if (!files || files.length === 0) return
const zipFile = files[0]
await importWorkspace(zipFile)
// Reset file input
if (event.target) {
event.target.value = ''
}
},
[importWorkspace]
)
/**
* Register global commands:
* - Mod+Shift+A: Add an Agent block to the canvas
@@ -386,6 +455,10 @@ export function SidebarNew() {
isCollapsed={isCollapsed}
onRenameWorkspace={handleRenameWorkspace}
onDeleteWorkspace={handleDeleteWorkspace}
onDuplicateWorkspace={handleDuplicateWorkspace}
onExportWorkspace={handleExportWorkspace}
onImportWorkspace={handleImportWorkspace}
isImportingWorkspace={isImportingWorkspace}
/>
</div>
) : (
@@ -414,6 +487,10 @@ export function SidebarNew() {
isCollapsed={isCollapsed}
onRenameWorkspace={handleRenameWorkspace}
onDeleteWorkspace={handleDeleteWorkspace}
onDuplicateWorkspace={handleDuplicateWorkspace}
onExportWorkspace={handleExportWorkspace}
onImportWorkspace={handleImportWorkspace}
isImportingWorkspace={isImportingWorkspace}
/>
</div>
@@ -452,7 +529,7 @@ export function SidebarNew() {
</Button>
</Tooltip.Trigger>
<Tooltip.Content className='py-[2.5px]'>
<p>{isImporting ? 'Importing workflow...' : 'Import from JSON'}</p>
<p>{isImporting ? 'Importing workflow...' : 'Import workflow'}</p>
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
@@ -529,6 +606,15 @@ export function SidebarNew() {
workspaces={searchModalWorkspaces}
isOnWorkflowPage={!!workflowId}
/>
{/* Hidden file input for workspace import */}
<input
ref={workspaceFileInputRef}
type='file'
accept='.zip'
style={{ display: 'none' }}
onChange={handleWorkspaceFileChange}
/>
</>
)
}

View File

@@ -24,7 +24,7 @@ import {
WorkspaceSelector,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components'
import { InviteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/workspace-header/components/invite-modal/invite-modal'
import { useAutoScroll } from '@/app/workspace/[workspaceId]/w/hooks/use-auto-scroll'
import { useAutoScroll } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-auto-scroll'
import { useKnowledgeBasesList } from '@/hooks/use-knowledge'
import { useSubscriptionStore } from '@/stores/subscription/store'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'

View File

@@ -1,5 +1,9 @@
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 { useDuplicateWorkspace } from './use-duplicate-workspace'
export { useExportWorkflow } from './use-export-workflow'
export { useExportWorkspace } from './use-export-workspace'
export { useImportWorkflow } from './use-import-workflow'
export { useImportWorkspace } from './use-import-workspace'

View File

@@ -0,0 +1,93 @@
import { useCallback, useState } from 'react'
import { useRouter } from 'next/navigation'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('useDuplicateWorkspace')
interface UseDuplicateWorkspaceProps {
/**
* Function that returns the workspace ID to duplicate
* This function is called when duplication occurs to get fresh state
*/
getWorkspaceId: () => string | null
/**
* Optional callback after successful duplication
*/
onSuccess?: () => void
}
/**
* Hook for managing workspace duplication.
*
* Handles:
* - Workspace duplication
* - Calling duplicate API
* - Loading state management
* - Error handling and logging
* - Navigation to duplicated workspace
*
* @param props - Hook configuration
* @returns Duplicate workspace handlers and state
*/
export function useDuplicateWorkspace({ getWorkspaceId, onSuccess }: UseDuplicateWorkspaceProps) {
const router = useRouter()
const [isDuplicating, setIsDuplicating] = useState(false)
/**
* Duplicate the workspace
*/
const handleDuplicateWorkspace = useCallback(
async (workspaceName: string) => {
if (isDuplicating) {
return
}
setIsDuplicating(true)
try {
// Get fresh workspace ID at duplication time
const workspaceId = getWorkspaceId()
if (!workspaceId) {
return
}
const response = await fetch(`/api/workspaces/${workspaceId}/duplicate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: `${workspaceName} (Copy)`,
}),
})
if (!response.ok) {
throw new Error(`Failed to duplicate workspace: ${response.statusText}`)
}
const duplicatedWorkspace = await response.json()
logger.info('Workspace duplicated successfully', {
sourceWorkspaceId: workspaceId,
newWorkspaceId: duplicatedWorkspace.id,
workflowsCount: duplicatedWorkspace.workflowsCount,
})
// Navigate to duplicated workspace
router.push(`/workspace/${duplicatedWorkspace.id}/w`)
onSuccess?.()
return duplicatedWorkspace.id
} catch (error) {
logger.error('Error duplicating workspace:', { error })
throw error
} finally {
setIsDuplicating(false)
}
},
[getWorkspaceId, isDuplicating, router, onSuccess]
)
return {
isDuplicating,
handleDuplicateWorkspace,
}
}

View File

@@ -0,0 +1,211 @@
import { useCallback, useState } from 'react'
import JSZip from 'jszip'
import { createLogger } from '@/lib/logs/console/logger'
import { sanitizeForExport } from '@/lib/workflows/json-sanitizer'
import { useFolderStore } from '@/stores/folders/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('useExportWorkflow')
interface UseExportWorkflowProps {
/**
* Current workspace ID
*/
workspaceId: string
/**
* Function that returns the workflow ID(s) to export
* This function is called when export occurs to get fresh selection state
*/
getWorkflowIds: () => string | string[]
/**
* Optional callback after successful export
*/
onSuccess?: () => void
}
/**
* Hook for managing workflow export to JSON.
*
* Handles:
* - Single or bulk workflow export
* - Fetching workflow data and variables from API
* - Sanitizing workflow state for export
* - Downloading as JSON file(s)
* - Loading state management
* - Error handling and logging
* - Clearing selection after export
*
* @param props - Hook configuration
* @returns Export workflow handlers and state
*/
export function useExportWorkflow({
workspaceId,
getWorkflowIds,
onSuccess,
}: UseExportWorkflowProps) {
const { workflows } = useWorkflowRegistry()
const [isExporting, setIsExporting] = useState(false)
/**
* Download file helper
*/
const downloadFile = (
content: Blob | string,
filename: string,
mimeType = 'application/json'
) => {
try {
const blob = content instanceof Blob ? content : new Blob([content], { type: mimeType })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch (error) {
logger.error('Failed to download file:', error)
}
}
/**
* Export the workflow(s) to JSON or ZIP
* - Single workflow: exports as JSON file
* - Multiple workflows: exports as ZIP file containing all JSON files
* Fetches workflow data from API to support bulk export of non-active workflows
*/
const handleExportWorkflow = useCallback(async () => {
if (isExporting) {
return
}
setIsExporting(true)
try {
// Get fresh workflow IDs at export time
const workflowIdsOrId = getWorkflowIds()
if (!workflowIdsOrId) {
return
}
// Normalize to array for consistent handling
const workflowIdsToExport = Array.isArray(workflowIdsOrId)
? workflowIdsOrId
: [workflowIdsOrId]
logger.info('Starting workflow export', {
workflowIdsToExport,
count: workflowIdsToExport.length,
})
const exportedWorkflows: Array<{ name: string; content: string }> = []
// Export each workflow
for (const workflowId of workflowIdsToExport) {
try {
const workflow = workflows[workflowId]
if (!workflow) {
logger.warn(`Workflow ${workflowId} not found in registry`)
continue
}
// Fetch workflow state from API
const workflowResponse = await fetch(`/api/workflows/${workflowId}`)
if (!workflowResponse.ok) {
logger.error(`Failed to fetch workflow ${workflowId}`)
continue
}
const { data: workflowData } = await workflowResponse.json()
if (!workflowData?.state) {
logger.warn(`Workflow ${workflowId} has no state`)
continue
}
// Fetch workflow variables
const variablesResponse = await fetch(`/api/workflows/${workflowId}/variables`)
let workflowVariables: any[] = []
if (variablesResponse.ok) {
const variablesData = await variablesResponse.json()
workflowVariables = Object.values(variablesData?.data || {}).map((v: any) => ({
id: v.id,
name: v.name,
type: v.type,
value: v.value,
}))
}
// Prepare export state
const workflowState = {
...workflowData.state,
metadata: {
name: workflow.name,
description: workflow.description,
color: workflow.color,
exportedAt: new Date().toISOString(),
},
variables: workflowVariables,
}
const exportState = sanitizeForExport(workflowState)
const jsonString = JSON.stringify(exportState, null, 2)
exportedWorkflows.push({
name: workflow.name,
content: jsonString,
})
logger.info(`Workflow ${workflowId} exported successfully`)
} catch (error) {
logger.error(`Failed to export workflow ${workflowId}:`, error)
}
}
if (exportedWorkflows.length === 0) {
logger.warn('No workflows were successfully exported')
return
}
// Download as single JSON or ZIP depending on count
if (exportedWorkflows.length === 1) {
// Single workflow - download as JSON
const filename = `${exportedWorkflows[0].name.replace(/[^a-z0-9]/gi, '-')}.json`
downloadFile(exportedWorkflows[0].content, filename, 'application/json')
} else {
// Multiple workflows - download as ZIP
const zip = new JSZip()
for (const exportedWorkflow of exportedWorkflows) {
const filename = `${exportedWorkflow.name.replace(/[^a-z0-9]/gi, '-')}.json`
zip.file(filename, exportedWorkflow.content)
}
const zipBlob = await zip.generateAsync({ type: 'blob' })
const zipFilename = `workflows-export-${Date.now()}.zip`
downloadFile(zipBlob, zipFilename, 'application/zip')
}
// Clear selection after successful export
const { clearSelection } = useFolderStore.getState()
clearSelection()
logger.info('Workflow(s) exported successfully', {
workflowIds: workflowIdsToExport,
count: exportedWorkflows.length,
format: exportedWorkflows.length === 1 ? 'JSON' : 'ZIP',
})
onSuccess?.()
} catch (error) {
logger.error('Error exporting workflow(s):', { error })
throw error
} finally {
setIsExporting(false)
}
}, [getWorkflowIds, isExporting, workflows, onSuccess])
return {
isExporting,
handleExportWorkflow,
}
}

View File

@@ -0,0 +1,147 @@
import { useCallback, useState } from 'react'
import { createLogger } from '@/lib/logs/console/logger'
import { exportWorkspaceToZip, type WorkflowExportData } from '@/lib/workflows/import-export'
const logger = createLogger('useExportWorkspace')
interface UseExportWorkspaceProps {
/**
* Optional callback after successful export
*/
onSuccess?: () => void
}
/**
* Hook for managing workspace export to ZIP.
*
* Handles:
* - Fetching all workflows and folders from workspace
* - Fetching workflow states and variables
* - Creating ZIP file with all workspace data
* - Downloading the ZIP file
* - Loading state management
* - Error handling and logging
*
* @param props - Hook configuration
* @returns Export workspace handlers and state
*/
export function useExportWorkspace({ onSuccess }: UseExportWorkspaceProps = {}) {
const [isExporting, setIsExporting] = useState(false)
/**
* Export workspace to ZIP file
*/
const handleExportWorkspace = useCallback(
async (workspaceId: string, workspaceName: string) => {
if (isExporting) return
setIsExporting(true)
try {
logger.info('Exporting workspace', { workspaceId })
// Fetch all workflows in workspace
const workflowsResponse = await fetch(`/api/workflows?workspaceId=${workspaceId}`)
if (!workflowsResponse.ok) {
throw new Error('Failed to fetch workflows')
}
const { data: workflows } = await workflowsResponse.json()
// Fetch all folders in workspace
const foldersResponse = await fetch(`/api/folders?workspaceId=${workspaceId}`)
if (!foldersResponse.ok) {
throw new Error('Failed to fetch folders')
}
const foldersData = await foldersResponse.json()
// Export each workflow
const workflowsToExport: WorkflowExportData[] = []
for (const workflow of workflows) {
try {
const workflowResponse = await fetch(`/api/workflows/${workflow.id}`)
if (!workflowResponse.ok) {
logger.warn(`Failed to fetch workflow ${workflow.id}`)
continue
}
const { data: workflowData } = await workflowResponse.json()
if (!workflowData?.state) {
logger.warn(`Workflow ${workflow.id} has no state`)
continue
}
const variablesResponse = await fetch(`/api/workflows/${workflow.id}/variables`)
let workflowVariables: any[] = []
if (variablesResponse.ok) {
const variablesData = await variablesResponse.json()
workflowVariables = Object.values(variablesData?.data || {}).map((v: any) => ({
id: v.id,
name: v.name,
type: v.type,
value: v.value,
}))
}
workflowsToExport.push({
workflow: {
id: workflow.id,
name: workflow.name,
description: workflow.description,
color: workflow.color,
folderId: workflow.folderId,
},
state: workflowData.state,
variables: workflowVariables,
})
} catch (error) {
logger.error(`Failed to export workflow ${workflow.id}:`, error)
}
}
const foldersToExport: Array<{
id: string
name: string
parentId: string | null
}> = (foldersData.folders || []).map((folder: any) => ({
id: folder.id,
name: folder.name,
parentId: folder.parentId,
}))
const zipBlob = await exportWorkspaceToZip(
workspaceName,
workflowsToExport,
foldersToExport
)
const blobUrl = URL.createObjectURL(zipBlob)
const a = document.createElement('a')
a.href = blobUrl
a.download = `${workspaceName.replace(/[^a-z0-9]/gi, '-')}-${Date.now()}.zip`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(blobUrl)
logger.info('Workspace exported successfully', {
workspaceId,
workflowsCount: workflowsToExport.length,
foldersCount: foldersToExport.length,
})
onSuccess?.()
} catch (error) {
logger.error('Error exporting workspace:', error)
throw error
} finally {
setIsExporting(false)
}
},
[isExporting, onSuccess]
)
return {
isExporting,
handleExportWorkspace,
}
}

View File

@@ -0,0 +1,210 @@
import { useCallback, useState } from 'react'
import { useRouter } from 'next/navigation'
import { createLogger } from '@/lib/logs/console/logger'
import {
extractWorkflowName,
extractWorkflowsFromFiles,
extractWorkflowsFromZip,
} from '@/lib/workflows/import-export'
import { useFolderStore } from '@/stores/folders/store'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import { parseWorkflowJson } from '@/stores/workflows/json/importer'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('useImportWorkflow')
interface UseImportWorkflowProps {
workspaceId: string
}
/**
* Custom hook to handle workflow import functionality.
* Supports importing from:
* - Single JSON file
* - Multiple JSON files
* - ZIP file containing multiple workflows with folder structure
*
* @param props - Configuration object containing workspaceId
* @returns Import state and handlers
*/
export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
const router = useRouter()
const { createWorkflow, loadWorkflows } = useWorkflowRegistry()
const [isImporting, setIsImporting] = useState(false)
/**
* Import a single workflow
*/
const importSingleWorkflow = useCallback(
async (content: string, filename: string, folderId?: string) => {
const { data: workflowData, errors: parseErrors } = parseWorkflowJson(content)
if (!workflowData || parseErrors.length > 0) {
logger.warn(`Failed to parse ${filename}:`, parseErrors)
return null
}
const workflowName = extractWorkflowName(content, filename)
useWorkflowDiffStore.getState().clearDiff()
// Extract color from metadata
const parsedContent = JSON.parse(content)
const workflowColor =
parsedContent.state?.metadata?.color || parsedContent.metadata?.color || '#3972F6'
const newWorkflowId = await createWorkflow({
name: workflowName,
description: workflowData.metadata?.description || 'Imported from JSON',
workspaceId,
folderId: folderId || undefined,
})
// Update workflow color if we extracted one
if (workflowColor !== '#3972F6') {
await fetch(`/api/workflows/${newWorkflowId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ color: workflowColor }),
})
}
// Save workflow state
await fetch(`/api/workflows/${newWorkflowId}/state`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(workflowData),
})
// Save variables if any
if (workflowData.variables && workflowData.variables.length > 0) {
const variablesPayload = workflowData.variables.map((v: any) => ({
id: typeof v.id === 'string' && v.id.trim() ? v.id : crypto.randomUUID(),
workflowId: newWorkflowId,
name: v.name,
type: v.type,
value: v.value,
}))
await fetch(`/api/workflows/${newWorkflowId}/variables`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ variables: variablesPayload }),
})
}
logger.info(`Imported workflow: ${workflowName}`)
return newWorkflowId
},
[createWorkflow, workspaceId]
)
/**
* Handle file selection and read
*/
const handleFileChange = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files
if (!files || files.length === 0) return
setIsImporting(true)
try {
const fileArray = Array.from(files)
const hasZip = fileArray.some((f) => f.name.toLowerCase().endsWith('.zip'))
const jsonFiles = fileArray.filter((f) => f.name.toLowerCase().endsWith('.json'))
const importedWorkflowIds: string[] = []
if (hasZip && fileArray.length === 1) {
// Import from ZIP - preserves folder structure
const zipFile = fileArray[0]
const { workflows: extractedWorkflows, metadata } = await extractWorkflowsFromZip(zipFile)
const { createFolder } = useFolderStore.getState()
const folderName = metadata?.workspaceName || zipFile.name.replace(/\.zip$/i, '')
const importFolder = await createFolder({ name: folderName, workspaceId })
const folderMap = new Map<string, string>()
for (const workflow of extractedWorkflows) {
try {
let targetFolderId = importFolder.id
// Recreate nested folder structure
if (workflow.folderPath.length > 0) {
const folderPathKey = workflow.folderPath.join('/')
if (!folderMap.has(folderPathKey)) {
let parentId = importFolder.id
for (let i = 0; i < workflow.folderPath.length; i++) {
const pathSegment = workflow.folderPath.slice(0, i + 1).join('/')
if (!folderMap.has(pathSegment)) {
const subFolder = await createFolder({
name: workflow.folderPath[i],
workspaceId,
parentId,
})
folderMap.set(pathSegment, subFolder.id)
parentId = subFolder.id
} else {
parentId = folderMap.get(pathSegment)!
}
}
}
targetFolderId = folderMap.get(folderPathKey)!
}
const workflowId = await importSingleWorkflow(
workflow.content,
workflow.name,
targetFolderId
)
if (workflowId) importedWorkflowIds.push(workflowId)
} catch (error) {
logger.error(`Failed to import ${workflow.name}:`, error)
}
}
} else if (jsonFiles.length > 0) {
// Import multiple JSON files or single JSON
const extractedWorkflows = await extractWorkflowsFromFiles(jsonFiles)
for (const workflow of extractedWorkflows) {
try {
const workflowId = await importSingleWorkflow(workflow.content, workflow.name)
if (workflowId) importedWorkflowIds.push(workflowId)
} catch (error) {
logger.error(`Failed to import ${workflow.name}:`, error)
}
}
}
// Reload workflows to show newly imported ones
await loadWorkflows(workspaceId)
await useFolderStore.getState().fetchFolders(workspaceId)
logger.info(`Import complete. Imported ${importedWorkflowIds.length} workflow(s)`)
// Navigate to first imported workflow if any
if (importedWorkflowIds.length > 0) {
router.push(`/workspace/${workspaceId}/w/${importedWorkflowIds[0]}`)
}
} catch (error) {
logger.error('Failed to import workflows:', error)
} finally {
setIsImporting(false)
// Reset file input
if (event.target) {
event.target.value = ''
}
}
},
[importSingleWorkflow, workspaceId, loadWorkflows, router]
)
return {
isImporting,
handleFileChange,
}
}

View File

@@ -0,0 +1,202 @@
import { useCallback, useState } from 'react'
import { useRouter } from 'next/navigation'
import { createLogger } from '@/lib/logs/console/logger'
import { extractWorkflowName, extractWorkflowsFromZip } from '@/lib/workflows/import-export'
import { useFolderStore } from '@/stores/folders/store'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import { parseWorkflowJson } from '@/stores/workflows/json/importer'
const logger = createLogger('useImportWorkspace')
interface UseImportWorkspaceProps {
/**
* Optional callback after successful import
*/
onSuccess?: () => void
}
/**
* Hook for managing workspace import from ZIP files.
*
* Handles:
* - Extracting workflows from ZIP file
* - Creating new workspace
* - Recreating folder structure
* - Importing all workflows with states and variables
* - Navigation to imported workspace
* - Loading state management
* - Error handling and logging
*
* @param props - Hook configuration
* @returns Import workspace handlers and state
*/
export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {}) {
const router = useRouter()
const [isImporting, setIsImporting] = useState(false)
/**
* Handle workspace import from ZIP file
*/
const handleImportWorkspace = useCallback(
async (zipFile: File) => {
if (isImporting) {
return
}
if (!zipFile.name.toLowerCase().endsWith('.zip')) {
logger.error('Please select a ZIP file')
return
}
setIsImporting(true)
try {
logger.info('Importing workspace from ZIP')
// Extract workflows from ZIP
const { workflows: extractedWorkflows, metadata } = await extractWorkflowsFromZip(zipFile)
if (extractedWorkflows.length === 0) {
logger.warn('No workflows found in ZIP file')
return
}
// Create new workspace
const workspaceName = metadata?.workspaceName || zipFile.name.replace(/\.zip$/i, '')
const createResponse = await fetch('/api/workspaces', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: workspaceName }),
})
if (!createResponse.ok) {
throw new Error('Failed to create workspace')
}
const { workspace: newWorkspace } = await createResponse.json()
logger.info('Created new workspace:', newWorkspace)
const { createFolder } = useFolderStore.getState()
const folderMap = new Map<string, string>()
// Import workflows
for (const workflow of extractedWorkflows) {
try {
const { data: workflowData, errors: parseErrors } = parseWorkflowJson(workflow.content)
if (!workflowData || parseErrors.length > 0) {
logger.warn(`Failed to parse ${workflow.name}:`, parseErrors)
continue
}
// Recreate folder structure
let targetFolderId: string | null = null
if (workflow.folderPath.length > 0) {
const folderPathKey = workflow.folderPath.join('/')
if (!folderMap.has(folderPathKey)) {
let parentId: string | null = null
for (let i = 0; i < workflow.folderPath.length; i++) {
const pathSegment = workflow.folderPath.slice(0, i + 1).join('/')
if (!folderMap.has(pathSegment)) {
const subFolder = await createFolder({
name: workflow.folderPath[i],
workspaceId: newWorkspace.id,
parentId: parentId || undefined,
})
folderMap.set(pathSegment, subFolder.id)
parentId = subFolder.id
} else {
parentId = folderMap.get(pathSegment)!
}
}
}
targetFolderId = folderMap.get(folderPathKey) || null
}
const workflowName = extractWorkflowName(workflow.content, workflow.name)
useWorkflowDiffStore.getState().clearDiff()
// Extract color from workflow metadata
const parsedContent = JSON.parse(workflow.content)
const workflowColor =
parsedContent.state?.metadata?.color || parsedContent.metadata?.color || '#3972F6'
// Create workflow
const createWorkflowResponse = await fetch('/api/workflows', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: workflowName,
description: workflowData.metadata?.description || 'Imported from workspace export',
color: workflowColor,
workspaceId: newWorkspace.id,
folderId: targetFolderId,
}),
})
if (!createWorkflowResponse.ok) {
logger.error(`Failed to create workflow ${workflowName}`)
continue
}
const newWorkflow = await createWorkflowResponse.json()
// Save workflow state
const stateResponse = await fetch(`/api/workflows/${newWorkflow.id}/state`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(workflowData),
})
if (!stateResponse.ok) {
logger.error(`Failed to save workflow state for ${newWorkflow.id}`)
continue
}
// Save variables if any
if (workflowData.variables && workflowData.variables.length > 0) {
const variablesPayload = workflowData.variables.map((v: any) => ({
id: typeof v.id === 'string' && v.id.trim() ? v.id : crypto.randomUUID(),
workflowId: newWorkflow.id,
name: v.name,
type: v.type,
value: v.value,
}))
await fetch(`/api/workflows/${newWorkflow.id}/variables`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ variables: variablesPayload }),
})
}
logger.info(`Imported workflow: ${workflowName}`)
} catch (error) {
logger.error(`Failed to import ${workflow.name}:`, error)
}
}
logger.info(`Workspace import complete. Imported ${extractedWorkflows.length} workflows`)
// Navigate to new workspace
router.push(`/workspace/${newWorkspace.id}/w`)
onSuccess?.()
} catch (error) {
logger.error('Error importing workspace:', error)
throw error
} finally {
setIsImporting(false)
}
},
[isImporting, router, onSuccess]
)
return {
isImporting,
handleImportWorkspace,
}
}

View File

@@ -10,6 +10,7 @@ export interface WorkflowExportData {
id: string
name: string
description?: string
color?: string
folderId?: string | null
}
state: WorkflowState
@@ -83,6 +84,7 @@ export async function exportWorkspaceToZip(
metadata: {
name: workflow.workflow.name,
description: workflow.workflow.description,
color: workflow.workflow.color,
exportedAt: new Date().toISOString(),
},
variables: workflow.variables,

View File

@@ -0,0 +1,167 @@
import { db } from '@sim/db'
import { permissions, workflow, workflowFolder, workspace as workspaceTable } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { createLogger } from '@/lib/logs/console/logger'
import { getUserEntityPermissions } from '@/lib/permissions/utils'
import { duplicateWorkflow } from '@/lib/workflows/duplicate'
const logger = createLogger('WorkspaceDuplicate')
interface DuplicateWorkspaceOptions {
sourceWorkspaceId: string
userId: string
name: string
requestId?: string
}
interface DuplicateWorkspaceResult {
id: string
name: string
ownerId: string
workflowsCount: number
foldersCount: number
}
/**
* Duplicate a workspace with all its workflows
* This creates a new workspace and duplicates all workflows from the source workspace
*/
export async function duplicateWorkspace(
options: DuplicateWorkspaceOptions
): Promise<DuplicateWorkspaceResult> {
const { sourceWorkspaceId, userId, name, requestId = 'unknown' } = options
// Generate new workspace ID
const newWorkspaceId = crypto.randomUUID()
const now = new Date()
// Verify the source workspace exists and user has permission
const sourceWorkspace = await db
.select()
.from(workspaceTable)
.where(eq(workspaceTable.id, sourceWorkspaceId))
.limit(1)
.then((rows) => rows[0])
if (!sourceWorkspace) {
throw new Error('Source workspace not found')
}
// Check if user has permission to access the source workspace
const userPermission = await getUserEntityPermissions(userId, 'workspace', sourceWorkspaceId)
if (!userPermission) {
throw new Error('Source workspace not found or access denied')
}
// Create new workspace with admin permission in a transaction
await db.transaction(async (tx) => {
// Create the new workspace
await tx.insert(workspaceTable).values({
id: newWorkspaceId,
name,
ownerId: userId,
billedAccountUserId: userId,
allowPersonalApiKeys: sourceWorkspace.allowPersonalApiKeys,
createdAt: now,
updatedAt: now,
})
// Grant admin permission to the user on the new workspace
await tx.insert(permissions).values({
id: crypto.randomUUID(),
userId,
entityType: 'workspace',
entityId: newWorkspaceId,
permissionType: 'admin',
createdAt: now,
updatedAt: now,
})
})
// Get all folders from the source workspace
const sourceFolders = await db
.select()
.from(workflowFolder)
.where(eq(workflowFolder.workspaceId, sourceWorkspaceId))
// Create folder ID mapping
const folderIdMap = new Map<string, string>()
// Duplicate folders (need to maintain hierarchy)
const foldersByParent = new Map<string | null, typeof sourceFolders>()
for (const folder of sourceFolders) {
const parentKey = folder.parentId
if (!foldersByParent.has(parentKey)) {
foldersByParent.set(parentKey, [])
}
foldersByParent.get(parentKey)!.push(folder)
}
// Recursive function to duplicate folders in correct order
const duplicateFolderHierarchy = async (parentId: string | null) => {
const foldersAtLevel = foldersByParent.get(parentId) || []
for (const sourceFolder of foldersAtLevel) {
const newFolderId = crypto.randomUUID()
folderIdMap.set(sourceFolder.id, newFolderId)
await db.insert(workflowFolder).values({
id: newFolderId,
userId,
workspaceId: newWorkspaceId,
name: sourceFolder.name,
color: sourceFolder.color,
parentId: parentId ? folderIdMap.get(parentId) || null : null,
sortOrder: sourceFolder.sortOrder,
isExpanded: false,
createdAt: now,
updatedAt: now,
})
// Recursively duplicate child folders
await duplicateFolderHierarchy(sourceFolder.id)
}
}
// Start duplication from root level (parentId = null)
await duplicateFolderHierarchy(null)
// Get all workflows from the source workspace
const sourceWorkflows = await db
.select()
.from(workflow)
.where(eq(workflow.workspaceId, sourceWorkspaceId))
// Duplicate each workflow with mapped folder IDs
let workflowsCount = 0
for (const sourceWorkflow of sourceWorkflows) {
try {
const newFolderId = sourceWorkflow.folderId
? folderIdMap.get(sourceWorkflow.folderId) || null
: null
await duplicateWorkflow({
sourceWorkflowId: sourceWorkflow.id,
userId,
name: sourceWorkflow.name,
description: sourceWorkflow.description || undefined,
color: sourceWorkflow.color || undefined,
workspaceId: newWorkspaceId,
folderId: newFolderId,
requestId,
})
workflowsCount++
} catch (error) {
logger.error(`Failed to duplicate workflow ${sourceWorkflow.id}:`, error)
// Continue with other workflows even if one fails
}
}
return {
id: newWorkspaceId,
name,
ownerId: userId,
workflowsCount,
foldersCount: folderIdMap.size,
}
}