mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
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:
79
apps/sim/app/api/workspaces/[id]/duplicate/route.ts
Normal file
79
apps/sim/app/api/workspaces/[id]/duplicate/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -87,10 +87,8 @@ export function useMentionKeyboard({
|
||||
openSubmenuFor,
|
||||
mentionActiveIndex,
|
||||
submenuActiveIndex,
|
||||
inAggregated,
|
||||
setMentionActiveIndex,
|
||||
setSubmenuActiveIndex,
|
||||
setInAggregated,
|
||||
setOpenSubmenuFor,
|
||||
setSubmenuQueryStart,
|
||||
getCaretPos,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
167
apps/sim/lib/workspaces/duplicate.ts
Normal file
167
apps/sim/lib/workspaces/duplicate.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user