diff --git a/apps/sim/app/api/workspaces/[id]/duplicate/route.ts b/apps/sim/app/api/workspaces/[id]/duplicate/route.ts new file mode 100644 index 0000000000..0dbd33803c --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/duplicate/route.ts @@ -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 }) + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/components/mention-menu/mention-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/components/mention-menu/mention-menu.tsx index fc9755edfd..2df4500863 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/components/mention-menu/mention-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/components/mention-menu/mention-menu.tsx @@ -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({ > - {showAggregatedView ? ( + {openSubmenuFor ? ( + // Submenu view - showing contents of a specific folder + <> + {openSubmenuFor === 'Chats' && ( + <> + {mentionData.isLoadingPastChats ? ( + + ) : mentionData.pastChats.length === 0 ? ( + + ) : ( + mentionData.pastChats.map((chat, index) => ( + insertPastChatMention(chat)} + data-idx={index} + active={index === submenuActiveIndex} + > + {chat.title || 'New Chat'} + + )) + )} + + )} + {openSubmenuFor === 'Workflows' && ( + <> + {mentionData.isLoadingWorkflows ? ( + + ) : mentionData.workflows.length === 0 ? ( + + ) : ( + mentionData.workflows.map((wf, index) => ( + insertWorkflowMention(wf)} + data-idx={index} + active={index === submenuActiveIndex} + > +
+ {wf.name || 'Untitled Workflow'} + + )) + )} + + )} + {openSubmenuFor === 'Knowledge' && ( + <> + {mentionData.isLoadingKnowledge ? ( + + ) : mentionData.knowledgeBases.length === 0 ? ( + + ) : ( + mentionData.knowledgeBases.map((kb, index) => ( + insertKnowledgeMention(kb)} + data-idx={index} + active={index === submenuActiveIndex} + > + {kb.name || 'Untitled'} + + )) + )} + + )} + {openSubmenuFor === 'Blocks' && ( + <> + {mentionData.isLoadingBlocks ? ( + + ) : mentionData.blocksList.length === 0 ? ( + + ) : ( + mentionData.blocksList.map((blk, index) => { + const Icon = blk.iconComponent + return ( + insertBlockMention(blk)} + data-idx={index} + active={index === submenuActiveIndex} + > +
+ {Icon && } +
+ {blk.name || blk.id} +
+ ) + }) + )} + + )} + {openSubmenuFor === 'Workflow Blocks' && ( + <> + {mentionData.isLoadingWorkflowBlocks ? ( + + ) : mentionData.workflowBlocks.length === 0 ? ( + + ) : ( + mentionData.workflowBlocks.map((blk, index) => { + const Icon = blk.iconComponent + return ( + insertWorkflowBlockMention(blk)} + data-idx={index} + active={index === submenuActiveIndex} + > +
+ {Icon && } +
+ {blk.name || blk.id} +
+ ) + }) + )} + + )} + {openSubmenuFor === 'Templates' && ( + <> + {mentionData.isLoadingTemplates ? ( + + ) : mentionData.templatesList.length === 0 ? ( + + ) : ( + mentionData.templatesList.map((tpl, index) => ( + insertTemplateMention(tpl)} + data-idx={index} + active={index === submenuActiveIndex} + > + {tpl.name} + + {tpl.stars} + + + )) + )} + + )} + {openSubmenuFor === 'Logs' && ( + <> + {mentionData.isLoadingLogs ? ( + + ) : mentionData.logsList.length === 0 ? ( + + ) : ( + mentionData.logsList.map((log, index) => ( + insertLogMention(log)} + data-idx={index} + active={index === submenuActiveIndex} + > + {log.workflowName} + · + + {formatTimestamp(log.createdAt)} + + · + + {(log.trigger || 'manual').toLowerCase()} + + + )) + )} + + )} + + ) : 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 ? ( @@ -424,6 +620,8 @@ export function MentionMenu({ id='workflows' title='All workflows' onOpen={() => mentionData.ensureWorkflowsLoaded()} + active={isInFolderNavigationMode && mentionActiveIndex === 1} + data-idx={1} > {mentionData.isLoadingWorkflows ? ( @@ -446,6 +644,8 @@ export function MentionMenu({ id='knowledge' title='Knowledge Bases' onOpen={() => mentionData.ensureKnowledgeLoaded()} + active={isInFolderNavigationMode && mentionActiveIndex === 2} + data-idx={2} > {mentionData.isLoadingKnowledge ? ( @@ -464,6 +664,8 @@ export function MentionMenu({ id='blocks' title='Blocks' onOpen={() => mentionData.ensureBlocksLoaded()} + active={isInFolderNavigationMode && mentionActiveIndex === 3} + data-idx={3} > {mentionData.isLoadingBlocks ? ( @@ -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 ? ( @@ -518,6 +722,8 @@ export function MentionMenu({ id='templates' title='Templates' onOpen={() => mentionData.ensureTemplatesLoaded()} + active={isInFolderNavigationMode && mentionActiveIndex === 5} + data-idx={5} > {mentionData.isLoadingTemplates ? ( @@ -535,7 +741,13 @@ export function MentionMenu({ )} - mentionData.ensureLogsLoaded()}> + mentionData.ensureLogsLoaded()} + active={isInFolderNavigationMode && mentionActiveIndex === 6} + data-idx={6} + > {mentionData.isLoadingLogs ? ( ) : mentionData.logsList.length === 0 ? ( @@ -557,7 +769,12 @@ export function MentionMenu({ )} - insertDocsMention()}> + insertDocsMention()} + active={isInFolderNavigationMode && mentionActiveIndex === 7} + data-idx={7} + > Docs diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/constants.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/constants.ts index fa9faa01a7..8b45f58abd 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/constants.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/constants.ts @@ -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 /** diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/hooks/use-mention-data.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/hooks/use-mention-data.ts index 4c8efec3f9..ee40d2671d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/hooks/use-mention-data.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/hooks/use-mention-data.ts @@ -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([]) const [isLoadingPastChats, setIsLoadingPastChats] = useState(false) - const [workflows, setWorkflows] = useState([]) - const [isLoadingWorkflows, setIsLoadingWorkflows] = useState(false) - const [knowledgeBases, setKnowledgeBases] = useState([]) 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 diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/hooks/use-mention-keyboard.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/hooks/use-mention-keyboard.ts index 1de94ecfcd..f12a88e63d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/hooks/use-mention-keyboard.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/hooks/use-mention-keyboard.ts @@ -87,10 +87,8 @@ export function useMentionKeyboard({ openSubmenuFor, mentionActiveIndex, submenuActiveIndex, - inAggregated, setMentionActiveIndex, setSubmenuActiveIndex, - setInAggregated, setOpenSubmenuFor, setSubmenuQueryStart, getCaretPos, diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/context-menu/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/context-menu/context-menu.tsx index c2aa3c652a..615a2cfe38 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/context-menu/context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/context-menu/context-menu.tsx @@ -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 ( @@ -75,7 +97,7 @@ export function ContextMenu({ }} /> - {showRename && ( + {showRename && onRename && ( { onRename() @@ -86,6 +108,17 @@ export function ContextMenu({ Rename )} + {showCreate && onCreate && ( + { + onCreate() + onClose() + }} + > + + Create workflow + + )} {showDuplicate && onDuplicate && ( { @@ -97,6 +130,17 @@ export function ContextMenu({ Duplicate )} + {showExport && onExport && ( + { + onExport() + onClose() + }} + > + + Export + + )} { onDelete() diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/folder-item/folder-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/folder-item/folder-item.tsx index 6666395606..908d6dacb8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/folder-item/folder-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/folder-item/folder-item.tsx @@ -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 */} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/workflow-item/workflow-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/workflow-item/workflow-item.tsx index 63d216888b..fdbb144bc1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/workflow-item/workflow-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/workflow-item/workflow-item.tsx @@ -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 */} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/workflow-list.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/workflow-list.tsx index f5e4743a34..9c90ec623e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/workflow-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/workflow-list.tsx @@ -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({ diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workspace-header/workspace-header.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workspace-header/workspace-header.tsx index 0c5ad1f7c3..321d64c0ca 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workspace-header/workspace-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workspace-header/workspace-header.tsx @@ -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 + /** + * Callback to duplicate the workspace + */ + onDuplicateWorkspace: (workspaceId: string, workspaceName: string) => Promise + /** + * Callback to export the workspace + */ + onExportWorkspace: (workspaceId: string, workspaceName: string) => Promise + /** + * 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(null) - const [settingsWorkspaceId, setSettingsWorkspaceId] = useState(null) const [editingWorkspaceId, setEditingWorkspaceId] = useState(null) const [editingName, setEditingName] = useState('') const [isListRenaming, setIsListRenaming] = useState(false) const listRenameInputRef = useRef(null) + // Context menu state + const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 }) + const [isContextMenuOpen, setIsContextMenuOpen] = useState(false) + const contextMenuRef = useRef(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 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) + } + + /** + * Close context menu + */ + const closeContextMenu = () => { + setIsContextMenuOpen(false) + } + + /** + * Handles rename action from context menu */ - const handleRenameAction = (workspace: Workspace) => { - setSettingsWorkspaceId(null) - setEditingWorkspaceId(workspace.id) - setEditingName(workspace.name) + 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 settings menu + * Handles delete action from context menu */ - const handleDeleteAction = (workspace: Workspace) => { - setSettingsWorkspaceId(null) - setDeleteTarget(workspace) - setIsDeleteModalOpen(true) + 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 {/* Workspace Switcher Popover */} - + { + // Don't close if context menu is opening + if (!open && isContextMenuOpen) { + return + } + setIsWorkspaceMenuOpen(open) + }} + > +
+ + + + + +

+ {isImportingWorkspace ? 'Importing workspace...' : 'Import workspace'} +

+
+
+ + + + + +

{isCreatingWorkspace ? 'Creating workspace...' : 'Create workspace'}

+
+
+
{workspaces.map((workspace, index) => ( @@ -312,48 +422,13 @@ export function WorkspaceHeader({ />
) : ( -
- onWorkspaceSwitch(workspace)} - className='flex-1 pr-[28px]' - > - {workspace.name} - - - setSettingsWorkspaceId(open ? workspace.id : null) - } - > - - - - - handleRenameAction(workspace)}> - - Rename - - handleDeleteAction(workspace)} - className='mt-[2px]' - > - - Delete - - - -
+ onWorkspaceSwitch(workspace)} + onContextMenu={(e) => handleContextMenu(e, workspace)} + > + {workspace.name} + )} ))} @@ -373,6 +448,22 @@ export function WorkspaceHeader({ + + {/* Context Menu */} + + {/* Invite Modal */} { - 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) => { - 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, - } -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar-new.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar-new.tsx index ceeb906b50..e8477ee9fd 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar-new.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar-new.tsx @@ -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(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) => { + 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} /> ) : ( @@ -414,6 +487,10 @@ export function SidebarNew() { isCollapsed={isCollapsed} onRenameWorkspace={handleRenameWorkspace} onDeleteWorkspace={handleDeleteWorkspace} + onDuplicateWorkspace={handleDuplicateWorkspace} + onExportWorkspace={handleExportWorkspace} + onImportWorkspace={handleImportWorkspace} + isImportingWorkspace={isImportingWorkspace} /> @@ -452,7 +529,7 @@ export function SidebarNew() { -

{isImporting ? 'Importing workflow...' : 'Import from JSON'}

+

{isImporting ? 'Importing workflow...' : 'Import workflow'}

@@ -529,6 +606,15 @@ export function SidebarNew() { workspaces={searchModalWorkspaces} isOnWorkflowPage={!!workflowId} /> + + {/* Hidden file input for workspace import */} + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index e94c49a07a..bc5687e6f3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -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' diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/index.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/index.ts index 6883903d62..5b4c1153ad 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/index.ts @@ -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' diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-workspace.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-workspace.ts new file mode 100644 index 0000000000..0781e86e43 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-workspace.ts @@ -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, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workflow.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workflow.ts new file mode 100644 index 0000000000..453d7c8718 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workflow.ts @@ -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, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workspace.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workspace.ts new file mode 100644 index 0000000000..6581f0eae0 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workspace.ts @@ -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, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workflow.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workflow.ts new file mode 100644 index 0000000000..8e4733cac2 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workflow.ts @@ -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) => { + 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() + + 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, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workspace.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workspace.ts new file mode 100644 index 0000000000..04f2c1c190 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workspace.ts @@ -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() + + // 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, + } +} diff --git a/apps/sim/lib/workflows/import-export.ts b/apps/sim/lib/workflows/import-export.ts index b02a1b70b9..b2d6ec240e 100644 --- a/apps/sim/lib/workflows/import-export.ts +++ b/apps/sim/lib/workflows/import-export.ts @@ -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, diff --git a/apps/sim/lib/workspaces/duplicate.ts b/apps/sim/lib/workspaces/duplicate.ts new file mode 100644 index 0000000000..4678911aa5 --- /dev/null +++ b/apps/sim/lib/workspaces/duplicate.ts @@ -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 { + 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() + + // Duplicate folders (need to maintain hierarchy) + const foldersByParent = new Map() + 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, + } +}