Skip to content

Commit 7dc4919

Browse files
committed
feat(reorder): allow workflow/folder reordering
1 parent 40a066f commit 7dc4919

File tree

15 files changed

+821
-358
lines changed

15 files changed

+821
-358
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/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: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { db } from '@sim/db'
22
import { workflow, workspace } 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'
@@ -131,11 +131,23 @@ export async function POST(req: NextRequest) {
131131
// Silently fail
132132
})
133133

134+
const folderCondition = folderId ? eq(workflow.folderId, folderId) : isNull(workflow.folderId)
135+
const [maxResult] = await db
136+
.select({ maxOrder: max(workflow.sortOrder) })
137+
.from(workflow)
138+
.where(
139+
workspaceId
140+
? and(eq(workflow.workspaceId, workspaceId), folderCondition)
141+
: and(eq(workflow.userId, session.user.id), folderCondition)
142+
)
143+
const sortOrder = (maxResult?.maxOrder ?? -1) + 1
144+
134145
await db.insert(workflow).values({
135146
id: workflowId,
136147
userId: session.user.id,
137148
workspaceId: workspaceId || null,
138149
folderId: folderId || null,
150+
sortOrder,
139151
name,
140152
description,
141153
color,
@@ -156,6 +168,7 @@ export async function POST(req: NextRequest) {
156168
color,
157169
workspaceId,
158170
folderId,
171+
sortOrder,
159172
createdAt: now,
160173
updatedAt: now,
161174
})

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ interface FolderItemProps {
3636
onDragEnter?: (e: React.DragEvent<HTMLElement>) => void
3737
onDragLeave?: (e: React.DragEvent<HTMLElement>) => void
3838
}
39+
onDragStart?: () => void
40+
onDragEnd?: () => void
3941
}
4042

4143
/**
@@ -46,7 +48,13 @@ interface FolderItemProps {
4648
* @param props - Component props
4749
* @returns Folder item with drag and expand support
4850
*/
49-
export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
51+
export function FolderItem({
52+
folder,
53+
level,
54+
hoverHandlers,
55+
onDragStart: onDragStartProp,
56+
onDragEnd: onDragEndProp,
57+
}: FolderItemProps) {
5058
const params = useParams()
5159
const router = useRouter()
5260
const workspaceId = params.workspaceId as string
@@ -136,11 +144,6 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
136144
}
137145
}, [createFolderMutation, workspaceId, folder.id, expandFolder])
138146

139-
/**
140-
* Drag start handler - sets folder data for drag operation
141-
*
142-
* @param e - React drag event
143-
*/
144147
const onDragStart = useCallback(
145148
(e: React.DragEvent) => {
146149
if (isEditing) {
@@ -150,14 +153,25 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
150153

151154
e.dataTransfer.setData('folder-id', folder.id)
152155
e.dataTransfer.effectAllowed = 'move'
156+
onDragStartProp?.()
153157
},
154-
[folder.id]
158+
[folder.id, onDragStartProp]
155159
)
156160

157-
const { isDragging, shouldPreventClickRef, handleDragStart, handleDragEnd } = useItemDrag({
161+
const {
162+
isDragging,
163+
shouldPreventClickRef,
164+
handleDragStart,
165+
handleDragEnd: handleDragEndBase,
166+
} = useItemDrag({
158167
onDragStart,
159168
})
160169

170+
const handleDragEnd = useCallback(() => {
171+
handleDragEndBase()
172+
onDragEndProp?.()
173+
}, [handleDragEndBase, onDragEndProp])
174+
161175
const {
162176
isOpen: isContextMenuOpen,
163177
position,

0 commit comments

Comments
 (0)