Skip to content

Commit 6c8c3d6

Browse files
feat(reorder): allow workflow/folder reordering (#2818)
* feat(reorder): allow workflow/folder reordering * progress * fix edge cases * add migration * fix bun lock * updated to use brand tertiary color, allow worfklows to be dropped above/below folders at the same level * cahnged color, removed flicker on folder container * optimized * ack pr comments * removed empty placeholder images for drag, removed redundant local sanitization helper --------- Co-authored-by: waleed <walif6@gmail.com>
1 parent 3f1dccd commit 6c8c3d6

File tree

26 files changed

+11505
-444
lines changed

26 files changed

+11505
-444
lines changed

apps/sim/app/api/folders/[id]/route.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const updateFolderSchema = z.object({
1414
color: z.string().optional(),
1515
isExpanded: z.boolean().optional(),
1616
parentId: z.string().nullable().optional(),
17+
sortOrder: z.number().int().min(0).optional(),
1718
})
1819

1920
// PUT - Update a folder
@@ -38,7 +39,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
3839
return NextResponse.json({ error: `Validation failed: ${errorMessages}` }, { status: 400 })
3940
}
4041

41-
const { name, color, isExpanded, parentId } = validationResult.data
42+
const { name, color, isExpanded, parentId, sortOrder } = validationResult.data
4243

4344
// Verify the folder exists
4445
const existingFolder = await db
@@ -81,12 +82,12 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
8182
}
8283
}
8384

84-
// Update the folder
85-
const updates: any = { updatedAt: new Date() }
85+
const updates: Record<string, unknown> = { updatedAt: new Date() }
8686
if (name !== undefined) updates.name = name.trim()
8787
if (color !== undefined) updates.color = color
8888
if (isExpanded !== undefined) updates.isExpanded = isExpanded
8989
if (parentId !== undefined) updates.parentId = parentId || null
90+
if (sortOrder !== undefined) updates.sortOrder = sortOrder
9091

9192
const [updatedFolder] = await db
9293
.update(workflowFolder)
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { db } from '@sim/db'
2+
import { workflowFolder } from '@sim/db/schema'
3+
import { createLogger } from '@sim/logger'
4+
import { eq, inArray } from 'drizzle-orm'
5+
import { type NextRequest, NextResponse } from 'next/server'
6+
import { z } from 'zod'
7+
import { getSession } from '@/lib/auth'
8+
import { generateRequestId } from '@/lib/core/utils/request'
9+
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
10+
11+
const logger = createLogger('FolderReorderAPI')
12+
13+
const ReorderSchema = z.object({
14+
workspaceId: z.string(),
15+
updates: z.array(
16+
z.object({
17+
id: z.string(),
18+
sortOrder: z.number().int().min(0),
19+
parentId: z.string().nullable().optional(),
20+
})
21+
),
22+
})
23+
24+
export async function PUT(req: NextRequest) {
25+
const requestId = generateRequestId()
26+
const session = await getSession()
27+
28+
if (!session?.user?.id) {
29+
logger.warn(`[${requestId}] Unauthorized folder reorder attempt`)
30+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
31+
}
32+
33+
try {
34+
const body = await req.json()
35+
const { workspaceId, updates } = ReorderSchema.parse(body)
36+
37+
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
38+
if (!permission || permission === 'read') {
39+
logger.warn(
40+
`[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}`
41+
)
42+
return NextResponse.json({ error: 'Write access required' }, { status: 403 })
43+
}
44+
45+
const folderIds = updates.map((u) => u.id)
46+
const existingFolders = await db
47+
.select({ id: workflowFolder.id, workspaceId: workflowFolder.workspaceId })
48+
.from(workflowFolder)
49+
.where(inArray(workflowFolder.id, folderIds))
50+
51+
const validIds = new Set(
52+
existingFolders.filter((f) => f.workspaceId === workspaceId).map((f) => f.id)
53+
)
54+
55+
const validUpdates = updates.filter((u) => validIds.has(u.id))
56+
57+
if (validUpdates.length === 0) {
58+
return NextResponse.json({ error: 'No valid folders to update' }, { status: 400 })
59+
}
60+
61+
await db.transaction(async (tx) => {
62+
for (const update of validUpdates) {
63+
const updateData: Record<string, unknown> = {
64+
sortOrder: update.sortOrder,
65+
updatedAt: new Date(),
66+
}
67+
if (update.parentId !== undefined) {
68+
updateData.parentId = update.parentId
69+
}
70+
await tx.update(workflowFolder).set(updateData).where(eq(workflowFolder.id, update.id))
71+
}
72+
})
73+
74+
logger.info(
75+
`[${requestId}] Reordered ${validUpdates.length} folders in workspace ${workspaceId}`
76+
)
77+
78+
return NextResponse.json({ success: true, updated: validUpdates.length })
79+
} catch (error) {
80+
if (error instanceof z.ZodError) {
81+
logger.warn(`[${requestId}] Invalid folder reorder data`, { errors: error.errors })
82+
return NextResponse.json(
83+
{ error: 'Invalid request data', details: error.errors },
84+
{ status: 400 }
85+
)
86+
}
87+
88+
logger.error(`[${requestId}] Error reordering folders`, error)
89+
return NextResponse.json({ error: 'Failed to reorder folders' }, { status: 500 })
90+
}
91+
}

apps/sim/app/api/folders/route.ts

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export async function POST(request: NextRequest) {
5858
}
5959

6060
const body = await request.json()
61-
const { name, workspaceId, parentId, color } = body
61+
const { name, workspaceId, parentId, color, sortOrder: providedSortOrder } = body
6262

6363
if (!name || !workspaceId) {
6464
return NextResponse.json({ error: 'Name and workspace ID are required' }, { status: 400 })
@@ -81,25 +81,26 @@ export async function POST(request: NextRequest) {
8181
// Generate a new ID
8282
const id = crypto.randomUUID()
8383

84-
// Use transaction to ensure sortOrder consistency
8584
const newFolder = await db.transaction(async (tx) => {
86-
// Get the next sort order for the parent (or root level)
87-
// Consider all folders in the workspace, not just those created by current user
88-
const existingFolders = await tx
89-
.select({ sortOrder: workflowFolder.sortOrder })
90-
.from(workflowFolder)
91-
.where(
92-
and(
93-
eq(workflowFolder.workspaceId, workspaceId),
94-
parentId ? eq(workflowFolder.parentId, parentId) : isNull(workflowFolder.parentId)
85+
let sortOrder: number
86+
if (providedSortOrder !== undefined) {
87+
sortOrder = providedSortOrder
88+
} else {
89+
const existingFolders = await tx
90+
.select({ sortOrder: workflowFolder.sortOrder })
91+
.from(workflowFolder)
92+
.where(
93+
and(
94+
eq(workflowFolder.workspaceId, workspaceId),
95+
parentId ? eq(workflowFolder.parentId, parentId) : isNull(workflowFolder.parentId)
96+
)
9597
)
96-
)
97-
.orderBy(desc(workflowFolder.sortOrder))
98-
.limit(1)
98+
.orderBy(desc(workflowFolder.sortOrder))
99+
.limit(1)
99100

100-
const nextSortOrder = existingFolders.length > 0 ? existingFolders[0].sortOrder + 1 : 0
101+
sortOrder = existingFolders.length > 0 ? existingFolders[0].sortOrder + 1 : 0
102+
}
101103

102-
// Insert the new folder within the same transaction
103104
const [folder] = await tx
104105
.insert(workflowFolder)
105106
.values({
@@ -109,7 +110,7 @@ export async function POST(request: NextRequest) {
109110
workspaceId,
110111
parentId: parentId || null,
111112
color: color || '#6B7280',
112-
sortOrder: nextSortOrder,
113+
sortOrder,
113114
})
114115
.returning()
115116

apps/sim/app/api/workflows/[id]/route.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const UpdateWorkflowSchema = z.object({
2020
description: z.string().optional(),
2121
color: z.string().optional(),
2222
folderId: z.string().nullable().optional(),
23+
sortOrder: z.number().int().min(0).optional(),
2324
})
2425

2526
/**
@@ -438,12 +439,12 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
438439
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
439440
}
440441

441-
// Build update object
442-
const updateData: any = { updatedAt: new Date() }
442+
const updateData: Record<string, unknown> = { updatedAt: new Date() }
443443
if (updates.name !== undefined) updateData.name = updates.name
444444
if (updates.description !== undefined) updateData.description = updates.description
445445
if (updates.color !== undefined) updateData.color = updates.color
446446
if (updates.folderId !== undefined) updateData.folderId = updates.folderId
447+
if (updates.sortOrder !== undefined) updateData.sortOrder = updates.sortOrder
447448

448449
// Update the workflow
449450
const [updatedWorkflow] = await db
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { db } from '@sim/db'
2+
import { workflow } from '@sim/db/schema'
3+
import { createLogger } from '@sim/logger'
4+
import { eq, inArray } from 'drizzle-orm'
5+
import { type NextRequest, NextResponse } from 'next/server'
6+
import { z } from 'zod'
7+
import { getSession } from '@/lib/auth'
8+
import { generateRequestId } from '@/lib/core/utils/request'
9+
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
10+
11+
const logger = createLogger('WorkflowReorderAPI')
12+
13+
const ReorderSchema = z.object({
14+
workspaceId: z.string(),
15+
updates: z.array(
16+
z.object({
17+
id: z.string(),
18+
sortOrder: z.number().int().min(0),
19+
folderId: z.string().nullable().optional(),
20+
})
21+
),
22+
})
23+
24+
export async function PUT(req: NextRequest) {
25+
const requestId = generateRequestId()
26+
const session = await getSession()
27+
28+
if (!session?.user?.id) {
29+
logger.warn(`[${requestId}] Unauthorized reorder attempt`)
30+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
31+
}
32+
33+
try {
34+
const body = await req.json()
35+
const { workspaceId, updates } = ReorderSchema.parse(body)
36+
37+
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
38+
if (!permission || permission === 'read') {
39+
logger.warn(
40+
`[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}`
41+
)
42+
return NextResponse.json({ error: 'Write access required' }, { status: 403 })
43+
}
44+
45+
const workflowIds = updates.map((u) => u.id)
46+
const existingWorkflows = await db
47+
.select({ id: workflow.id, workspaceId: workflow.workspaceId })
48+
.from(workflow)
49+
.where(inArray(workflow.id, workflowIds))
50+
51+
const validIds = new Set(
52+
existingWorkflows.filter((w) => w.workspaceId === workspaceId).map((w) => w.id)
53+
)
54+
55+
const validUpdates = updates.filter((u) => validIds.has(u.id))
56+
57+
if (validUpdates.length === 0) {
58+
return NextResponse.json({ error: 'No valid workflows to update' }, { status: 400 })
59+
}
60+
61+
await db.transaction(async (tx) => {
62+
for (const update of validUpdates) {
63+
const updateData: Record<string, unknown> = {
64+
sortOrder: update.sortOrder,
65+
updatedAt: new Date(),
66+
}
67+
if (update.folderId !== undefined) {
68+
updateData.folderId = update.folderId
69+
}
70+
await tx.update(workflow).set(updateData).where(eq(workflow.id, update.id))
71+
}
72+
})
73+
74+
logger.info(
75+
`[${requestId}] Reordered ${validUpdates.length} workflows in workspace ${workspaceId}`
76+
)
77+
78+
return NextResponse.json({ success: true, updated: validUpdates.length })
79+
} catch (error) {
80+
if (error instanceof z.ZodError) {
81+
logger.warn(`[${requestId}] Invalid reorder data`, { errors: error.errors })
82+
return NextResponse.json(
83+
{ error: 'Invalid request data', details: error.errors },
84+
{ status: 400 }
85+
)
86+
}
87+
88+
logger.error(`[${requestId}] Error reordering workflows`, error)
89+
return NextResponse.json({ error: 'Failed to reorder workflows' }, { status: 500 })
90+
}
91+
}

apps/sim/app/api/workflows/route.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { db } from '@sim/db'
22
import { workflow } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
4-
import { eq } from 'drizzle-orm'
4+
import { and, eq, isNull, max } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
66
import { z } from 'zod'
77
import { getSession } from '@/lib/auth'
@@ -17,6 +17,7 @@ const CreateWorkflowSchema = z.object({
1717
color: z.string().optional().default('#3972F6'),
1818
workspaceId: z.string().optional(),
1919
folderId: z.string().nullable().optional(),
20+
sortOrder: z.number().int().optional(),
2021
})
2122

2223
// GET /api/workflows - Get workflows for user (optionally filtered by workspaceId)
@@ -89,7 +90,14 @@ export async function POST(req: NextRequest) {
8990

9091
try {
9192
const body = await req.json()
92-
const { name, description, color, workspaceId, folderId } = CreateWorkflowSchema.parse(body)
93+
const {
94+
name,
95+
description,
96+
color,
97+
workspaceId,
98+
folderId,
99+
sortOrder: providedSortOrder,
100+
} = CreateWorkflowSchema.parse(body)
93101

94102
if (workspaceId) {
95103
const workspacePermission = await getUserEntityPermissions(
@@ -127,11 +135,28 @@ export async function POST(req: NextRequest) {
127135
// Silently fail
128136
})
129137

138+
let sortOrder: number
139+
if (providedSortOrder !== undefined) {
140+
sortOrder = providedSortOrder
141+
} else {
142+
const folderCondition = folderId ? eq(workflow.folderId, folderId) : isNull(workflow.folderId)
143+
const [maxResult] = await db
144+
.select({ maxOrder: max(workflow.sortOrder) })
145+
.from(workflow)
146+
.where(
147+
workspaceId
148+
? and(eq(workflow.workspaceId, workspaceId), folderCondition)
149+
: and(eq(workflow.userId, session.user.id), folderCondition)
150+
)
151+
sortOrder = (maxResult?.maxOrder ?? -1) + 1
152+
}
153+
130154
await db.insert(workflow).values({
131155
id: workflowId,
132156
userId: session.user.id,
133157
workspaceId: workspaceId || null,
134158
folderId: folderId || null,
159+
sortOrder,
135160
name,
136161
description,
137162
color,
@@ -152,6 +177,7 @@ export async function POST(req: NextRequest) {
152177
color,
153178
workspaceId,
154179
folderId,
180+
sortOrder,
155181
createdAt: now,
156182
updatedAt: now,
157183
})

0 commit comments

Comments
 (0)