diff --git a/apps/sim/app/api/workflows/route.ts b/apps/sim/app/api/workflows/route.ts index 407e9f0368..a35bb07526 100644 --- a/apps/sim/app/api/workflows/route.ts +++ b/apps/sim/app/api/workflows/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, isNull, max } from 'drizzle-orm' +import { and, asc, eq, isNull, min } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' @@ -64,10 +64,20 @@ export async function GET(request: Request) { let workflows + const orderByClause = [asc(workflow.sortOrder), asc(workflow.createdAt), asc(workflow.id)] + if (workspaceId) { - workflows = await db.select().from(workflow).where(eq(workflow.workspaceId, workspaceId)) + workflows = await db + .select() + .from(workflow) + .where(eq(workflow.workspaceId, workspaceId)) + .orderBy(...orderByClause) } else { - workflows = await db.select().from(workflow).where(eq(workflow.userId, userId)) + workflows = await db + .select() + .from(workflow) + .where(eq(workflow.userId, userId)) + .orderBy(...orderByClause) } return NextResponse.json({ data: workflows }, { status: 200 }) @@ -140,15 +150,15 @@ export async function POST(req: NextRequest) { sortOrder = providedSortOrder } else { const folderCondition = folderId ? eq(workflow.folderId, folderId) : isNull(workflow.folderId) - const [maxResult] = await db - .select({ maxOrder: max(workflow.sortOrder) }) + const [minResult] = await db + .select({ minOrder: min(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 + sortOrder = (minResult?.minOrder ?? 1) - 1 } await db.insert(workflow).values({ 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 a79a2b2024..363a6be072 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 @@ -18,6 +18,17 @@ const TREE_SPACING = { INDENT_PER_LEVEL: 20, } as const +function compareByOrder( + a: T, + b: T +): number { + if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder + const timeA = a.createdAt?.getTime() ?? 0 + const timeB = b.createdAt?.getTime() ?? 0 + if (timeA !== timeB) return timeA - timeB + return a.id.localeCompare(b.id) +} + interface WorkflowListProps { regularWorkflows: WorkflowMetadata[] isLoading?: boolean @@ -97,7 +108,7 @@ export function WorkflowList({ {} as Record ) for (const folderId of Object.keys(grouped)) { - grouped[folderId].sort((a, b) => a.sortOrder - b.sortOrder) + grouped[folderId].sort(compareByOrder) } return grouped }, [regularWorkflows]) @@ -208,6 +219,7 @@ export function WorkflowList({ type: 'folder' | 'workflow' id: string sortOrder: number + createdAt?: Date data: FolderTreeNode | WorkflowMetadata }> = [] for (const childFolder of folder.children) { @@ -215,6 +227,7 @@ export function WorkflowList({ type: 'folder', id: childFolder.id, sortOrder: childFolder.sortOrder, + createdAt: childFolder.createdAt, data: childFolder, }) } @@ -223,10 +236,11 @@ export function WorkflowList({ type: 'workflow', id: workflow.id, sortOrder: workflow.sortOrder, + createdAt: workflow.createdAt, data: workflow, }) } - childItems.sort((a, b) => a.sortOrder - b.sortOrder) + childItems.sort(compareByOrder) return (
@@ -294,20 +308,28 @@ export function WorkflowList({ type: 'folder' | 'workflow' id: string sortOrder: number + createdAt?: Date data: FolderTreeNode | WorkflowMetadata }> = [] for (const folder of folderTree) { - items.push({ type: 'folder', id: folder.id, sortOrder: folder.sortOrder, data: folder }) + items.push({ + type: 'folder', + id: folder.id, + sortOrder: folder.sortOrder, + createdAt: folder.createdAt, + data: folder, + }) } for (const workflow of rootWorkflows) { items.push({ type: 'workflow', id: workflow.id, sortOrder: workflow.sortOrder, + createdAt: workflow.createdAt, data: workflow, }) } - return items.sort((a, b) => a.sortOrder - b.sortOrder) + return items.sort(compareByOrder) }, [folderTree, rootWorkflows]) const hasRootItems = rootItems.length > 0 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 d6b4696933..e2613ff6a3 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 @@ -133,7 +133,20 @@ export function useDragDrop() { [] ) - type SiblingItem = { type: 'folder' | 'workflow'; id: string; sortOrder: number } + type SiblingItem = { + type: 'folder' | 'workflow' + id: string + sortOrder: number + createdAt: Date + } + + const compareSiblingItems = (a: SiblingItem, b: SiblingItem): number => { + if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder + const timeA = a.createdAt.getTime() + const timeB = b.createdAt.getTime() + if (timeA !== timeB) return timeA - timeB + return a.id.localeCompare(b.id) + } const getDestinationFolderId = useCallback((indicator: DropIndicator): string | null => { return indicator.position === 'inside' @@ -202,11 +215,21 @@ export function useDragDrop() { return [ ...Object.values(currentFolders) .filter((f) => f.parentId === folderId) - .map((f) => ({ type: 'folder' as const, id: f.id, sortOrder: f.sortOrder })), + .map((f) => ({ + type: 'folder' as const, + id: f.id, + sortOrder: f.sortOrder, + createdAt: f.createdAt, + })), ...Object.values(currentWorkflows) .filter((w) => w.folderId === folderId) - .map((w) => ({ type: 'workflow' as const, id: w.id, sortOrder: w.sortOrder })), - ].sort((a, b) => a.sortOrder - b.sortOrder) + .map((w) => ({ + type: 'workflow' as const, + id: w.id, + sortOrder: w.sortOrder, + createdAt: w.createdAt, + })), + ].sort(compareSiblingItems) }, []) const setNormalizedDropIndicator = useCallback( @@ -299,8 +322,9 @@ export function useDragDrop() { type: 'workflow' as const, id, sortOrder: currentWorkflows[id]?.sortOrder ?? 0, + createdAt: currentWorkflows[id]?.createdAt ?? new Date(), })) - .sort((a, b) => a.sortOrder - b.sortOrder) + .sort(compareSiblingItems) const insertAt = calculateInsertIndex(remaining, indicator) @@ -369,7 +393,12 @@ export function useDragDrop() { const newOrder: SiblingItem[] = [ ...remaining.slice(0, insertAt), - { type: 'folder', id: draggedFolderId, sortOrder: 0 }, + { + type: 'folder', + id: draggedFolderId, + sortOrder: 0, + createdAt: draggedFolder?.createdAt ?? new Date(), + }, ...remaining.slice(insertAt), ] diff --git a/apps/sim/hooks/queries/workflows.ts b/apps/sim/hooks/queries/workflows.ts index 6a119bdc9d..b6f5dc0673 100644 --- a/apps/sim/hooks/queries/workflows.ts +++ b/apps/sim/hooks/queries/workflows.ts @@ -194,7 +194,7 @@ export function useCreateWorkflow() { const workflowsInFolder = Object.values(currentWorkflows).filter( (w) => w.folderId === targetFolderId ) - sortOrder = workflowsInFolder.reduce((max, w) => Math.max(max, w.sortOrder ?? 0), -1) + 1 + sortOrder = workflowsInFolder.reduce((min, w) => Math.min(min, w.sortOrder ?? 0), 1) - 1 } return { @@ -294,7 +294,7 @@ export function useDuplicateWorkflowMutation() { const workflowsInFolder = Object.values(currentWorkflows).filter( (w) => w.folderId === targetFolderId ) - const maxSortOrder = workflowsInFolder.reduce((max, w) => Math.max(max, w.sortOrder ?? 0), -1) + const minSortOrder = workflowsInFolder.reduce((min, w) => Math.min(min, w.sortOrder ?? 0), 1) return { id: tempId, @@ -305,7 +305,7 @@ export function useDuplicateWorkflowMutation() { color: variables.color, workspaceId: variables.workspaceId, folderId: targetFolderId, - sortOrder: maxSortOrder + 1, + sortOrder: minSortOrder - 1, } } ) diff --git a/apps/sim/lib/workflows/persistence/duplicate.ts b/apps/sim/lib/workflows/persistence/duplicate.ts index 9a98309fe2..d73df91cc5 100644 --- a/apps/sim/lib/workflows/persistence/duplicate.ts +++ b/apps/sim/lib/workflows/persistence/duplicate.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { workflow, workflowBlocks, workflowEdges, workflowSubflows } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' +import { and, eq, isNull, min } from 'drizzle-orm' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import type { Variable } from '@/stores/panel/variables/types' import type { LoopConfig, ParallelConfig } from '@/stores/workflows/workflow/types' @@ -26,6 +26,7 @@ interface DuplicateWorkflowResult { color: string workspaceId: string folderId: string | null + sortOrder: number blocksCount: number edgesCount: number subflowsCount: number @@ -88,12 +89,29 @@ export async function duplicateWorkflow( throw new Error('Source workflow not found or access denied') } + const targetWorkspaceId = workspaceId || source.workspaceId + const targetFolderId = folderId !== undefined ? folderId : source.folderId + const folderCondition = targetFolderId + ? eq(workflow.folderId, targetFolderId) + : isNull(workflow.folderId) + + const [minResult] = await tx + .select({ minOrder: min(workflow.sortOrder) }) + .from(workflow) + .where( + targetWorkspaceId + ? and(eq(workflow.workspaceId, targetWorkspaceId), folderCondition) + : and(eq(workflow.userId, userId), folderCondition) + ) + const sortOrder = (minResult?.minOrder ?? 1) - 1 + // Create the new workflow first (required for foreign key constraints) await tx.insert(workflow).values({ id: newWorkflowId, userId, - workspaceId: workspaceId || source.workspaceId, - folderId: folderId !== undefined ? folderId : source.folderId, + workspaceId: targetWorkspaceId, + folderId: targetFolderId, + sortOrder, name, description: description || source.description, color: color || source.color, @@ -286,7 +304,8 @@ export async function duplicateWorkflow( description: description || source.description, color: color || source.color, workspaceId: finalWorkspaceId, - folderId: folderId !== undefined ? folderId : source.folderId, + folderId: targetFolderId, + sortOrder, blocksCount: sourceBlocks.length, edgesCount: sourceEdges.length, subflowsCount: sourceSubflows.length,