From f0d22246a7e5a2a9aa27d15a3e3e602e52f6f3d7 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 14 Jan 2026 10:33:19 -0800 Subject: [PATCH] progress --- apps/sim/app/api/folders/route.ts | 35 +++++----- apps/sim/app/api/workflows/route.ts | 35 +++++++--- .../workflow-item/workflow-item.tsx | 68 +++++++++++-------- .../workflow-list/workflow-list.tsx | 4 +- .../components/sidebar/hooks/use-drag-drop.ts | 7 +- .../w/hooks/use-export-folder.ts | 1 + .../w/hooks/use-export-workflow.ts | 1 + .../w/hooks/use-export-workspace.ts | 2 + .../w/hooks/use-import-workflow.ts | 41 ++++++++++- apps/sim/hooks/queries/folders.ts | 9 ++- apps/sim/hooks/queries/workflows.ts | 25 ++++--- .../lib/workflows/operations/import-export.ts | 37 +++++++++- .../workflows/sanitization/json-sanitizer.ts | 2 + 13 files changed, 190 insertions(+), 77 deletions(-) diff --git a/apps/sim/app/api/folders/route.ts b/apps/sim/app/api/folders/route.ts index e976f1a945..13f07f520f 100644 --- a/apps/sim/app/api/folders/route.ts +++ b/apps/sim/app/api/folders/route.ts @@ -58,7 +58,7 @@ export async function POST(request: NextRequest) { } const body = await request.json() - const { name, workspaceId, parentId, color } = body + const { name, workspaceId, parentId, color, sortOrder: providedSortOrder } = body if (!name || !workspaceId) { return NextResponse.json({ error: 'Name and workspace ID are required' }, { status: 400 }) @@ -81,25 +81,26 @@ export async function POST(request: NextRequest) { // Generate a new ID const id = crypto.randomUUID() - // Use transaction to ensure sortOrder consistency const newFolder = await db.transaction(async (tx) => { - // Get the next sort order for the parent (or root level) - // Consider all folders in the workspace, not just those created by current user - const existingFolders = await tx - .select({ sortOrder: workflowFolder.sortOrder }) - .from(workflowFolder) - .where( - and( - eq(workflowFolder.workspaceId, workspaceId), - parentId ? eq(workflowFolder.parentId, parentId) : isNull(workflowFolder.parentId) + let sortOrder: number + if (providedSortOrder !== undefined) { + sortOrder = providedSortOrder + } else { + const existingFolders = await tx + .select({ sortOrder: workflowFolder.sortOrder }) + .from(workflowFolder) + .where( + and( + eq(workflowFolder.workspaceId, workspaceId), + parentId ? eq(workflowFolder.parentId, parentId) : isNull(workflowFolder.parentId) + ) ) - ) - .orderBy(desc(workflowFolder.sortOrder)) - .limit(1) + .orderBy(desc(workflowFolder.sortOrder)) + .limit(1) - const nextSortOrder = existingFolders.length > 0 ? existingFolders[0].sortOrder + 1 : 0 + sortOrder = existingFolders.length > 0 ? existingFolders[0].sortOrder + 1 : 0 + } - // Insert the new folder within the same transaction const [folder] = await tx .insert(workflowFolder) .values({ @@ -109,7 +110,7 @@ export async function POST(request: NextRequest) { workspaceId, parentId: parentId || null, color: color || '#6B7280', - sortOrder: nextSortOrder, + sortOrder, }) .returning() diff --git a/apps/sim/app/api/workflows/route.ts b/apps/sim/app/api/workflows/route.ts index 788a74f542..f350b06c51 100644 --- a/apps/sim/app/api/workflows/route.ts +++ b/apps/sim/app/api/workflows/route.ts @@ -17,6 +17,7 @@ const CreateWorkflowSchema = z.object({ color: z.string().optional().default('#3972F6'), workspaceId: z.string().optional(), folderId: z.string().nullable().optional(), + sortOrder: z.number().int().optional(), }) // GET /api/workflows - Get workflows for user (optionally filtered by workspaceId) @@ -93,7 +94,14 @@ export async function POST(req: NextRequest) { try { const body = await req.json() - const { name, description, color, workspaceId, folderId } = CreateWorkflowSchema.parse(body) + const { + name, + description, + color, + workspaceId, + folderId, + sortOrder: providedSortOrder, + } = CreateWorkflowSchema.parse(body) if (workspaceId) { const workspacePermission = await getUserEntityPermissions( @@ -131,16 +139,21 @@ export async function POST(req: NextRequest) { // Silently fail }) - const folderCondition = folderId ? eq(workflow.folderId, folderId) : isNull(workflow.folderId) - const [maxResult] = await db - .select({ maxOrder: max(workflow.sortOrder) }) - .from(workflow) - .where( - workspaceId - ? and(eq(workflow.workspaceId, workspaceId), folderCondition) - : and(eq(workflow.userId, session.user.id), folderCondition) - ) - const sortOrder = (maxResult?.maxOrder ?? -1) + 1 + let sortOrder: number + if (providedSortOrder !== undefined) { + sortOrder = providedSortOrder + } else { + const folderCondition = folderId ? eq(workflow.folderId, folderId) : isNull(workflow.folderId) + const [maxResult] = await db + .select({ maxOrder: max(workflow.sortOrder) }) + .from(workflow) + .where( + workspaceId + ? and(eq(workflow.workspaceId, workspaceId), folderCondition) + : and(eq(workflow.userId, session.user.id), folderCondition) + ) + sortOrder = (maxResult?.maxOrder ?? -1) + 1 + } await db.insert(workflow).values({ id: workflowId, diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx index ed6225e374..0074681969 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx @@ -113,36 +113,7 @@ export function WorkflowItem({ [workflow.id, updateWorkflow] ) - const onDragStart = useCallback( - (e: React.DragEvent) => { - if (isEditing) { - e.preventDefault() - return - } - - const workflowIds = - isSelected && selectedWorkflows.size > 1 ? Array.from(selectedWorkflows) : [workflow.id] - - e.dataTransfer.setData('workflow-ids', JSON.stringify(workflowIds)) - e.dataTransfer.effectAllowed = 'move' - onDragStartProp?.() - }, - [isSelected, selectedWorkflows, workflow.id, onDragStartProp] - ) - - const { - isDragging, - shouldPreventClickRef, - handleDragStart, - handleDragEnd: handleDragEndBase, - } = useItemDrag({ - onDragStart, - }) - - const handleDragEnd = useCallback(() => { - handleDragEndBase() - onDragEndProp?.() - }, [handleDragEndBase, onDragEndProp]) + const isEditingRef = useRef(false) const { isOpen: isContextMenuOpen, @@ -247,6 +218,43 @@ export function WorkflowItem({ itemId: workflow.id, }) + isEditingRef.current = isEditing + + const onDragStart = useCallback( + (e: React.DragEvent) => { + if (isEditingRef.current) { + e.preventDefault() + return + } + + const currentSelection = useFolderStore.getState().selectedWorkflows + const isCurrentlySelected = currentSelection.has(workflow.id) + const workflowIds = + isCurrentlySelected && currentSelection.size > 1 + ? Array.from(currentSelection) + : [workflow.id] + + e.dataTransfer.setData('workflow-ids', JSON.stringify(workflowIds)) + e.dataTransfer.effectAllowed = 'move' + onDragStartProp?.() + }, + [workflow.id, onDragStartProp] + ) + + const { + isDragging, + shouldPreventClickRef, + handleDragStart, + handleDragEnd: handleDragEndBase, + } = useItemDrag({ + onDragStart, + }) + + const handleDragEnd = useCallback(() => { + handleDragEndBase() + onDragEndProp?.() + }, [handleDragEndBase, onDragEndProp]) + /** * Handle double-click on workflow name to enter rename mode */ diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/workflow-list.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/workflow-list.tsx index e608c3ba05..e6a811817e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/workflow-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/workflow-list.tsx @@ -241,7 +241,7 @@ export function WorkflowList({ - {isExpanded && ( + {isExpanded && (hasChildren || isDragging) && (
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-drag-drop.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-drag-drop.ts index 0a68e7dedd..3ad9736cd6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-drag-drop.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-drag-drop.ts @@ -158,7 +158,12 @@ export function useDragDrop() { const remaining = siblingItems.filter( (item) => !(item.type === 'workflow' && movingSet.has(item.id)) ) - const moving = workflowIds.map((id) => ({ type: 'workflow' as const, id, sortOrder: 0 })) + const moving = workflowIds + .map((id) => { + const w = currentWorkflows[id] + return { type: 'workflow' as const, id, sortOrder: w?.sortOrder ?? 0 } + }) + .sort((a, b) => a.sortOrder - b.sortOrder) let insertAt: number if (indicator.position === 'inside') { diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-folder.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-folder.ts index a35da0616f..b4e0b15aca 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-folder.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-folder.ts @@ -165,6 +165,7 @@ export function useExportFolder({ workspaceId, folderId, onSuccess }: UseExportF name: workflow.name, description: workflow.description, color: workflow.color, + sortOrder: workflow.sortOrder, exportedAt: new Date().toISOString(), }, variables: workflowVariables, 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 index dbfa1b8524..abe4cc8430 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workflow.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workflow.ts @@ -113,6 +113,7 @@ export function useExportWorkflow({ workspaceId, onSuccess }: UseExportWorkflowP name: workflow.name, description: workflow.description, color: workflow.color, + sortOrder: workflow.sortOrder, exportedAt: new Date().toISOString(), }, variables: workflowVariables, 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 index af663c92d0..cbb8377a84 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workspace.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workspace.ts @@ -86,6 +86,7 @@ export function useExportWorkspace({ onSuccess }: UseExportWorkspaceProps = {}) description: workflow.description, color: workflow.color, folderId: workflow.folderId, + sortOrder: workflow.sortOrder, }, state: workflowData.state, variables: workflowVariables, @@ -100,6 +101,7 @@ export function useExportWorkspace({ onSuccess }: UseExportWorkspaceProps = {}) id: folder.id, name: folder.name, parentId: folder.parentId, + sortOrder: folder.sortOrder, }) ) 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 index a5e190f05e..00bdf33f87 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workflow.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workflow.ts @@ -40,7 +40,7 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) { * Import a single workflow */ const importSingleWorkflow = useCallback( - async (content: string, filename: string, folderId?: string) => { + async (content: string, filename: string, folderId?: string, sortOrder?: number) => { const { data: workflowData, errors: parseErrors } = parseWorkflowJson(content) if (!workflowData || parseErrors.length > 0) { @@ -60,6 +60,7 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) { description: workflowData.metadata?.description || 'Imported from JSON', workspaceId, folderId: folderId || undefined, + sortOrder, }) const newWorkflowId = result.id @@ -140,6 +141,36 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) { }) const folderMap = new Map() + const exportedFoldersByPath = new Map() + if (metadata?.folders) { + type ExportedFolder = { + id: string + name: string + parentId: string | null + sortOrder?: number + } + const foldersById = new Map( + metadata.folders.map((f) => [f.id, f]) + ) + const sanitizeName = (name: string) => name.replace(/[^a-z0-9-_]/gi, '-') + + const buildPath = (folderId: string): string => { + const pathParts: string[] = [] + let currentId: string | null = folderId + while (currentId && foldersById.has(currentId)) { + const folder: ExportedFolder = foldersById.get(currentId)! + pathParts.unshift(sanitizeName(folder.name)) + currentId = folder.parentId + } + return pathParts.join('/') + } + + for (const f of metadata.folders) { + const path = buildPath(f.id) + exportedFoldersByPath.set(path, { sortOrder: f.sortOrder }) + } + } + for (const workflow of extractedWorkflows) { try { let targetFolderId = importFolder.id @@ -152,12 +183,15 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) { for (let i = 0; i < workflow.folderPath.length; i++) { const pathSegment = workflow.folderPath.slice(0, i + 1).join('/') + const folderNameForSegment = workflow.folderPath[i] if (!folderMap.has(pathSegment)) { + const exportedFolder = exportedFoldersByPath.get(pathSegment) const subFolder = await createFolderMutation.mutateAsync({ - name: workflow.folderPath[i], + name: folderNameForSegment, workspaceId, parentId, + sortOrder: exportedFolder?.sortOrder, }) folderMap.set(pathSegment, subFolder.id) parentId = subFolder.id @@ -173,7 +207,8 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) { const workflowId = await importSingleWorkflow( workflow.content, workflow.name, - targetFolderId + targetFolderId, + workflow.sortOrder ) if (workflowId) importedWorkflowIds.push(workflowId) } catch (error) { diff --git a/apps/sim/hooks/queries/folders.ts b/apps/sim/hooks/queries/folders.ts index ea0f83fa05..61289ed658 100644 --- a/apps/sim/hooks/queries/folders.ts +++ b/apps/sim/hooks/queries/folders.ts @@ -68,6 +68,7 @@ interface CreateFolderVariables { name: string parentId?: string color?: string + sortOrder?: number } interface UpdateFolderVariables { @@ -160,18 +161,20 @@ export function useCreateFolder() { parentId: variables.parentId || null, color: variables.color || '#808080', isExpanded: false, - sortOrder: getNextSortOrder(previousFolders, variables.workspaceId, variables.parentId), + sortOrder: + variables.sortOrder ?? + getNextSortOrder(previousFolders, variables.workspaceId, variables.parentId), createdAt: new Date(), updatedAt: new Date(), }) ) return useMutation({ - mutationFn: async ({ workspaceId, ...payload }: CreateFolderVariables) => { + mutationFn: async ({ workspaceId, sortOrder, ...payload }: CreateFolderVariables) => { const response = await fetch('/api/folders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ...payload, workspaceId }), + body: JSON.stringify({ ...payload, workspaceId, sortOrder }), }) if (!response.ok) { diff --git a/apps/sim/hooks/queries/workflows.ts b/apps/sim/hooks/queries/workflows.ts index c4921946a2..6a119bdc9d 100644 --- a/apps/sim/hooks/queries/workflows.ts +++ b/apps/sim/hooks/queries/workflows.ts @@ -92,6 +92,7 @@ interface CreateWorkflowVariables { description?: string color?: string folderId?: string | null + sortOrder?: number } interface CreateWorkflowResult { @@ -184,12 +185,17 @@ export function useCreateWorkflow() { queryClient, 'CreateWorkflow', (variables, tempId) => { - const currentWorkflows = useWorkflowRegistry.getState().workflows - const targetFolderId = variables.folderId || null - const workflowsInFolder = Object.values(currentWorkflows).filter( - (w) => w.folderId === targetFolderId - ) - const maxSortOrder = workflowsInFolder.reduce((max, w) => Math.max(max, w.sortOrder ?? 0), -1) + let sortOrder: number + if (variables.sortOrder !== undefined) { + sortOrder = variables.sortOrder + } else { + const currentWorkflows = useWorkflowRegistry.getState().workflows + const targetFolderId = variables.folderId || null + const workflowsInFolder = Object.values(currentWorkflows).filter( + (w) => w.folderId === targetFolderId + ) + sortOrder = workflowsInFolder.reduce((max, w) => Math.max(max, w.sortOrder ?? 0), -1) + 1 + } return { id: tempId, @@ -199,15 +205,15 @@ export function useCreateWorkflow() { description: variables.description || 'New workflow', color: variables.color || getNextWorkflowColor(), workspaceId: variables.workspaceId, - folderId: targetFolderId, - sortOrder: maxSortOrder + 1, + folderId: variables.folderId || null, + sortOrder, } } ) return useMutation({ mutationFn: async (variables: CreateWorkflowVariables): Promise => { - const { workspaceId, name, description, color, folderId } = variables + const { workspaceId, name, description, color, folderId, sortOrder } = variables logger.info(`Creating new workflow in workspace: ${workspaceId}`) @@ -220,6 +226,7 @@ export function useCreateWorkflow() { color: color || getNextWorkflowColor(), workspaceId, folderId: folderId || null, + sortOrder, }), }) diff --git a/apps/sim/lib/workflows/operations/import-export.ts b/apps/sim/lib/workflows/operations/import-export.ts index bfb96b63e8..107d960aa5 100644 --- a/apps/sim/lib/workflows/operations/import-export.ts +++ b/apps/sim/lib/workflows/operations/import-export.ts @@ -16,6 +16,7 @@ export interface WorkflowExportData { description?: string color?: string folderId?: string | null + sortOrder?: number } state: WorkflowState variables?: Record @@ -25,6 +26,7 @@ export interface FolderExportData { id: string name: string parentId: string | null + sortOrder?: number } export interface WorkspaceExportStructure { @@ -71,7 +73,12 @@ export async function exportWorkspaceToZip( name: workspaceName, exportedAt: new Date().toISOString(), }, - folders: folders.map((f) => ({ id: f.id, name: f.name, parentId: f.parentId })), + folders: folders.map((f) => ({ + id: f.id, + name: f.name, + parentId: f.parentId, + sortOrder: f.sortOrder, + })), } zip.file('_workspace.json', JSON.stringify(metadata, null, 2)) @@ -84,6 +91,7 @@ export async function exportWorkspaceToZip( name: workflow.workflow.name, description: workflow.workflow.description, color: workflow.workflow.color, + sortOrder: workflow.workflow.sortOrder, exportedAt: new Date().toISOString(), }, variables: workflow.variables, @@ -109,11 +117,18 @@ export interface ImportedWorkflow { content: string name: string folderPath: string[] + sortOrder?: number } export interface WorkspaceImportMetadata { workspaceName: string exportedAt?: string + folders?: Array<{ + id: string + name: string + parentId: string | null + sortOrder?: number + }> } export async function extractWorkflowsFromZip( @@ -133,6 +148,7 @@ export async function extractWorkflowsFromZip( metadata = { workspaceName: parsed.workspace?.name || 'Imported Workspace', exportedAt: parsed.workspace?.exportedAt, + folders: parsed.folders, } } catch (error) { logger.error('Failed to parse workspace metadata:', error) @@ -147,10 +163,19 @@ export async function extractWorkflowsFromZip( const pathParts = path.split('/').filter((p) => p.length > 0) const filename = pathParts.pop() || path + let sortOrder: number | undefined + try { + const parsed = JSON.parse(content) + sortOrder = parsed.state?.metadata?.sortOrder ?? parsed.metadata?.sortOrder + } catch { + // ignore parse errors for sortOrder extraction + } + workflows.push({ content, name: filename, folderPath: pathParts, + sortOrder, }) } catch (error) { logger.error(`Failed to extract ${path}:`, error) @@ -168,10 +193,20 @@ export async function extractWorkflowsFromFiles(files: File[]): Promise