fix(sidebar): unify workflow and folder insertion ordering (#3250)

* fix(sidebar): unify workflow and folder insertion ordering

* ack comments

* ack comments

* ack

* ack comment

* upgrade turbo

* fix build
This commit is contained in:
Waleed
2026-02-18 14:41:55 -08:00
committed by GitHub
parent cf28822a1c
commit 2979269ac3
13 changed files with 806 additions and 231 deletions

View File

@@ -1,7 +1,7 @@
import { db } from '@sim/db'
import { workflow, workflowFolder } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { and, eq, isNull, min } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
@@ -37,7 +37,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
logger.info(`[${requestId}] Duplicating folder ${sourceFolderId} for user ${session.user.id}`)
// Verify the source folder exists
const sourceFolder = await db
.select()
.from(workflowFolder)
@@ -48,7 +47,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
throw new Error('Source folder not found')
}
// Check if user has permission to access the source folder
const userPermission = await getUserEntityPermissions(
session.user.id,
'workspace',
@@ -61,26 +59,51 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
const targetWorkspaceId = workspaceId || sourceFolder.workspaceId
// Step 1: Duplicate folder structure
const { newFolderId, folderMapping } = await db.transaction(async (tx) => {
const newFolderId = crypto.randomUUID()
const now = new Date()
const targetParentId = parentId ?? sourceFolder.parentId
const folderParentCondition = targetParentId
? eq(workflowFolder.parentId, targetParentId)
: isNull(workflowFolder.parentId)
const workflowParentCondition = targetParentId
? eq(workflow.folderId, targetParentId)
: isNull(workflow.folderId)
const [[folderResult], [workflowResult]] = await Promise.all([
tx
.select({ minSortOrder: min(workflowFolder.sortOrder) })
.from(workflowFolder)
.where(and(eq(workflowFolder.workspaceId, targetWorkspaceId), folderParentCondition)),
tx
.select({ minSortOrder: min(workflow.sortOrder) })
.from(workflow)
.where(and(eq(workflow.workspaceId, targetWorkspaceId), workflowParentCondition)),
])
const minSortOrder = [folderResult?.minSortOrder, workflowResult?.minSortOrder].reduce<
number | null
>((currentMin, candidate) => {
if (candidate == null) return currentMin
if (currentMin == null) return candidate
return Math.min(currentMin, candidate)
}, null)
const sortOrder = minSortOrder != null ? minSortOrder - 1 : 0
// Create the new root folder
await tx.insert(workflowFolder).values({
id: newFolderId,
userId: session.user.id,
workspaceId: targetWorkspaceId,
name,
color: color || sourceFolder.color,
parentId: parentId || sourceFolder.parentId,
sortOrder: sourceFolder.sortOrder,
parentId: targetParentId,
sortOrder,
isExpanded: false,
createdAt: now,
updatedAt: now,
})
// Recursively duplicate child folders
const folderMapping = new Map<string, string>([[sourceFolderId, newFolderId]])
await duplicateFolderStructure(
tx,
@@ -96,7 +119,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
return { newFolderId, folderMapping }
})
// Step 2: Duplicate workflows
const workflowStats = await duplicateWorkflowsInFolderTree(
sourceFolder.workspaceId,
targetWorkspaceId,
@@ -173,7 +195,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
}
}
// Helper to recursively duplicate folder structure
async function duplicateFolderStructure(
tx: any,
sourceFolderId: string,
@@ -184,7 +205,6 @@ async function duplicateFolderStructure(
timestamp: Date,
folderMapping: Map<string, string>
): Promise<void> {
// Get all child folders
const childFolders = await tx
.select()
.from(workflowFolder)
@@ -195,7 +215,6 @@ async function duplicateFolderStructure(
)
)
// Create each child folder and recurse
for (const childFolder of childFolders) {
const newChildFolderId = crypto.randomUUID()
folderMapping.set(childFolder.id, newChildFolderId)
@@ -213,7 +232,6 @@ async function duplicateFolderStructure(
updatedAt: timestamp,
})
// Recurse for this child's children
await duplicateFolderStructure(
tx,
childFolder.id,
@@ -227,7 +245,6 @@ async function duplicateFolderStructure(
}
}
// Helper to duplicate all workflows in a folder tree
async function duplicateWorkflowsInFolderTree(
sourceWorkspaceId: string,
targetWorkspaceId: string,
@@ -237,9 +254,7 @@ async function duplicateWorkflowsInFolderTree(
): Promise<{ total: number; succeeded: number; failed: number }> {
const stats = { total: 0, succeeded: 0, failed: 0 }
// Process each folder in the mapping
for (const [oldFolderId, newFolderId] of folderMapping.entries()) {
// Get workflows in this folder
const workflowsInFolder = await db
.select()
.from(workflow)
@@ -247,7 +262,6 @@ async function duplicateWorkflowsInFolderTree(
stats.total += workflowsInFolder.length
// Duplicate each workflow
for (const sourceWorkflow of workflowsInFolder) {
try {
await duplicateWorkflow({

View File

@@ -10,9 +10,14 @@ import {
mockConsoleLogger,
setupCommonApiMocks,
} from '@sim/testing'
import { drizzleOrmMock } from '@sim/testing/mocks'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/audit/log', () => auditMock)
vi.mock('drizzle-orm', () => ({
...drizzleOrmMock,
min: vi.fn((field) => ({ type: 'min', field })),
}))
interface CapturedFolderValues {
name?: string
@@ -24,29 +29,35 @@ interface CapturedFolderValues {
}
function createMockTransaction(mockData: {
selectData?: Array<{ id: string; [key: string]: unknown }>
selectResults?: Array<Array<{ [key: string]: unknown }>>
insertResult?: Array<{ id: string; [key: string]: unknown }>
onInsertValues?: (values: CapturedFolderValues) => void
}) {
const { selectData = [], insertResult = [] } = mockData
return vi.fn().mockImplementation(async (callback: (tx: unknown) => Promise<unknown>) => {
const { selectResults = [[], []], insertResult = [], onInsertValues } = mockData
return async (callback: (tx: unknown) => Promise<unknown>) => {
const where = vi.fn()
for (const result of selectResults) {
where.mockReturnValueOnce(result)
}
where.mockReturnValue([])
const tx = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
orderBy: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue(selectData),
}),
}),
where,
}),
}),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockReturnValue(insertResult),
values: vi.fn().mockImplementation((values: CapturedFolderValues) => {
onInsertValues?.(values)
return {
returning: vi.fn().mockReturnValue(insertResult),
}
}),
}),
}
return await callback(tx)
})
}
}
describe('Folders API Route', () => {
@@ -257,25 +268,12 @@ describe('Folders API Route', () => {
it('should create a new folder successfully', async () => {
mockAuthenticatedUser()
mockTransaction.mockImplementationOnce(async (callback: any) => {
const tx = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
orderBy: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([]), // No existing folders
}),
}),
}),
}),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockReturnValue([mockFolders[0]]),
}),
}),
}
return await callback(tx)
})
mockTransaction.mockImplementationOnce(
createMockTransaction({
selectResults: [[], []],
insertResult: [mockFolders[0]],
})
)
const req = createMockRequest('POST', {
name: 'New Test Folder',
@@ -285,12 +283,11 @@ describe('Folders API Route', () => {
const { POST } = await import('@/app/api/folders/route')
const response = await POST(req)
const responseBody = await response.json()
expect(response.status).toBe(200)
const data = await response.json()
expect(data).toHaveProperty('folder')
expect(data.folder).toMatchObject({
expect(responseBody).toHaveProperty('folder')
expect(responseBody.folder).toMatchObject({
id: 'folder-1',
name: 'Test Folder 1',
workspaceId: 'workspace-123',
@@ -299,26 +296,17 @@ describe('Folders API Route', () => {
it('should create folder with correct sort order', async () => {
mockAuthenticatedUser()
let capturedValues: CapturedFolderValues | null = null
mockTransaction.mockImplementationOnce(async (callback: any) => {
const tx = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
orderBy: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([{ sortOrder: 5 }]), // Existing folder with sort order 5
}),
}),
}),
}),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockReturnValue([{ ...mockFolders[0], sortOrder: 6 }]),
}),
}),
}
return await callback(tx)
})
mockTransaction.mockImplementationOnce(
createMockTransaction({
selectResults: [[{ minSortOrder: 5 }], [{ minSortOrder: 2 }]],
insertResult: [{ ...mockFolders[0], sortOrder: 1 }],
onInsertValues: (values) => {
capturedValues = values
},
})
)
const req = createMockRequest('POST', {
name: 'New Test Folder',
@@ -332,8 +320,10 @@ describe('Folders API Route', () => {
const data = await response.json()
expect(data.folder).toMatchObject({
sortOrder: 6,
sortOrder: 1,
})
expect(capturedValues).not.toBeNull()
expect(capturedValues!.sortOrder).toBe(1)
})
it('should create subfolder with parent reference', async () => {
@@ -341,7 +331,7 @@ describe('Folders API Route', () => {
mockTransaction.mockImplementationOnce(
createMockTransaction({
selectData: [], // No existing folders
selectResults: [[], []],
insertResult: [{ ...mockFolders[1] }],
})
)
@@ -402,25 +392,12 @@ describe('Folders API Route', () => {
mockAuthenticatedUser()
mockGetUserEntityPermissions.mockResolvedValue('write') // Write permissions
mockTransaction.mockImplementationOnce(async (callback: any) => {
const tx = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
orderBy: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([]), // No existing folders
}),
}),
}),
}),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockReturnValue([mockFolders[0]]),
}),
}),
}
return await callback(tx)
})
mockTransaction.mockImplementationOnce(
createMockTransaction({
selectResults: [[], []],
insertResult: [mockFolders[0]],
})
)
const req = createMockRequest('POST', {
name: 'Test Folder',
@@ -440,25 +417,12 @@ describe('Folders API Route', () => {
mockAuthenticatedUser()
mockGetUserEntityPermissions.mockResolvedValue('admin') // Admin permissions
mockTransaction.mockImplementationOnce(async (callback: any) => {
const tx = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
orderBy: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([]), // No existing folders
}),
}),
}),
}),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockReturnValue([mockFolders[0]]),
}),
}),
}
return await callback(tx)
})
mockTransaction.mockImplementationOnce(
createMockTransaction({
selectResults: [[], []],
insertResult: [mockFolders[0]],
})
)
const req = createMockRequest('POST', {
name: 'Test Folder',
@@ -527,28 +491,15 @@ describe('Folders API Route', () => {
let capturedValues: CapturedFolderValues | null = null
mockTransaction.mockImplementationOnce(async (callback: any) => {
const tx = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
orderBy: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([]),
}),
}),
}),
}),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockImplementation((values) => {
capturedValues = values
return {
returning: vi.fn().mockReturnValue([mockFolders[0]]),
}
}),
}),
}
return await callback(tx)
})
mockTransaction.mockImplementationOnce(
createMockTransaction({
selectResults: [[], []],
insertResult: [mockFolders[0]],
onInsertValues: (values) => {
capturedValues = values
},
})
)
const req = createMockRequest('POST', {
name: ' Test Folder With Spaces ',
@@ -567,28 +518,15 @@ describe('Folders API Route', () => {
let capturedValues: CapturedFolderValues | null = null
mockTransaction.mockImplementationOnce(async (callback: any) => {
const tx = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
orderBy: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([]),
}),
}),
}),
}),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockImplementation((values) => {
capturedValues = values
return {
returning: vi.fn().mockReturnValue([mockFolders[0]]),
}
}),
}),
}
return await callback(tx)
})
mockTransaction.mockImplementationOnce(
createMockTransaction({
selectResults: [[], []],
insertResult: [mockFolders[0]],
onInsertValues: (values) => {
capturedValues = values
},
})
)
const req = createMockRequest('POST', {
name: 'Test Folder',

View File

@@ -1,7 +1,7 @@
import { db } from '@sim/db'
import { workflowFolder } from '@sim/db/schema'
import { workflow, workflowFolder } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, asc, desc, eq, isNull } from 'drizzle-orm'
import { and, asc, eq, isNull, min } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
@@ -87,19 +87,33 @@ export async function POST(request: NextRequest) {
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)
const folderParentCondition = parentId
? eq(workflowFolder.parentId, parentId)
: isNull(workflowFolder.parentId)
const workflowParentCondition = parentId
? eq(workflow.folderId, parentId)
: isNull(workflow.folderId)
sortOrder = existingFolders.length > 0 ? existingFolders[0].sortOrder + 1 : 0
const [[folderResult], [workflowResult]] = await Promise.all([
tx
.select({ minSortOrder: min(workflowFolder.sortOrder) })
.from(workflowFolder)
.where(and(eq(workflowFolder.workspaceId, workspaceId), folderParentCondition)),
tx
.select({ minSortOrder: min(workflow.sortOrder) })
.from(workflow)
.where(and(eq(workflow.workspaceId, workspaceId), workflowParentCondition)),
])
const minSortOrder = [folderResult?.minSortOrder, workflowResult?.minSortOrder].reduce<
number | null
>((currentMin, candidate) => {
if (candidate == null) return currentMin
if (currentMin == null) return candidate
return Math.min(currentMin, candidate)
}, null)
sortOrder = minSortOrder != null ? minSortOrder - 1 : 0
}
const [folder] = await tx

View File

@@ -0,0 +1,137 @@
/**
* @vitest-environment node
*/
import { auditMock, createMockRequest, mockConsoleLogger, setupCommonApiMocks } from '@sim/testing'
import { drizzleOrmMock } from '@sim/testing/mocks'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockCheckSessionOrInternalAuth = vi.fn()
const mockGetUserEntityPermissions = vi.fn()
const mockDbSelect = vi.fn()
const mockDbInsert = vi.fn()
const mockWorkflowCreated = vi.fn()
vi.mock('drizzle-orm', () => ({
...drizzleOrmMock,
min: vi.fn((field) => ({ type: 'min', field })),
}))
vi.mock('@/lib/audit/log', () => auditMock)
describe('Workflows API Route - POST ordering', () => {
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
setupCommonApiMocks()
mockConsoleLogger()
vi.stubGlobal('crypto', {
randomUUID: vi.fn().mockReturnValue('workflow-new-id'),
})
mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-123',
userName: 'Test User',
userEmail: 'test@example.com',
})
mockGetUserEntityPermissions.mockResolvedValue('write')
vi.doMock('@sim/db', () => ({
db: {
select: (...args: unknown[]) => mockDbSelect(...args),
insert: (...args: unknown[]) => mockDbInsert(...args),
},
}))
vi.doMock('@/lib/auth/hybrid', () => ({
checkSessionOrInternalAuth: (...args: unknown[]) => mockCheckSessionOrInternalAuth(...args),
}))
vi.doMock('@/lib/workspaces/permissions/utils', () => ({
getUserEntityPermissions: (...args: unknown[]) => mockGetUserEntityPermissions(...args),
workspaceExists: vi.fn(),
}))
vi.doMock('@/app/api/workflows/utils', () => ({
verifyWorkspaceMembership: vi.fn(),
}))
vi.doMock('@/lib/core/telemetry', () => ({
PlatformEvents: {
workflowCreated: (...args: unknown[]) => mockWorkflowCreated(...args),
},
}))
})
it('uses top insertion against mixed siblings (folders + workflows)', async () => {
const minResultsQueue: Array<Array<{ minOrder: number }>> = [
[{ minOrder: 5 }],
[{ minOrder: 2 }],
]
mockDbSelect.mockImplementation(() => ({
from: vi.fn().mockReturnValue({
where: vi.fn().mockImplementation(() => Promise.resolve(minResultsQueue.shift() ?? [])),
}),
}))
let insertedValues: Record<string, unknown> | null = null
mockDbInsert.mockReturnValue({
values: vi.fn().mockImplementation((values: Record<string, unknown>) => {
insertedValues = values
return Promise.resolve(undefined)
}),
})
const req = createMockRequest('POST', {
name: 'New Workflow',
description: 'desc',
color: '#3972F6',
workspaceId: 'workspace-123',
folderId: null,
})
const { POST } = await import('@/app/api/workflows/route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.sortOrder).toBe(1)
expect(insertedValues).not.toBeNull()
expect(insertedValues?.sortOrder).toBe(1)
})
it('defaults to sortOrder 0 when there are no siblings', async () => {
const minResultsQueue: Array<Array<{ minOrder: number }>> = [[], []]
mockDbSelect.mockImplementation(() => ({
from: vi.fn().mockReturnValue({
where: vi.fn().mockImplementation(() => Promise.resolve(minResultsQueue.shift() ?? [])),
}),
}))
let insertedValues: Record<string, unknown> | null = null
mockDbInsert.mockReturnValue({
values: vi.fn().mockImplementation((values: Record<string, unknown>) => {
insertedValues = values
return Promise.resolve(undefined)
}),
})
const req = createMockRequest('POST', {
name: 'New Workflow',
description: 'desc',
color: '#3972F6',
workspaceId: 'workspace-123',
folderId: null,
})
const { POST } = await import('@/app/api/workflows/route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.sortOrder).toBe(0)
expect(insertedValues?.sortOrder).toBe(0)
})
})

View File

@@ -1,5 +1,5 @@
import { db } from '@sim/db'
import { permissions, workflow } from '@sim/db/schema'
import { permissions, workflow, workflowFolder } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, asc, eq, inArray, isNull, min } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
@@ -162,12 +162,33 @@ export async function POST(req: NextRequest) {
if (providedSortOrder !== undefined) {
sortOrder = providedSortOrder
} else {
const folderCondition = folderId ? eq(workflow.folderId, folderId) : isNull(workflow.folderId)
const [minResult] = await db
.select({ minOrder: min(workflow.sortOrder) })
.from(workflow)
.where(and(eq(workflow.workspaceId, workspaceId), folderCondition))
sortOrder = (minResult?.minOrder ?? 1) - 1
const workflowParentCondition = folderId
? eq(workflow.folderId, folderId)
: isNull(workflow.folderId)
const folderParentCondition = folderId
? eq(workflowFolder.parentId, folderId)
: isNull(workflowFolder.parentId)
const [[workflowMinResult], [folderMinResult]] = await Promise.all([
db
.select({ minOrder: min(workflow.sortOrder) })
.from(workflow)
.where(and(eq(workflow.workspaceId, workspaceId), workflowParentCondition)),
db
.select({ minOrder: min(workflowFolder.sortOrder) })
.from(workflowFolder)
.where(and(eq(workflowFolder.workspaceId, workspaceId), folderParentCondition)),
])
const minSortOrder = [workflowMinResult?.minOrder, folderMinResult?.minOrder].reduce<
number | null
>((currentMin, candidate) => {
if (candidate == null) return currentMin
if (currentMin == null) return candidate
return Math.min(currentMin, candidate)
}, null)
sortOrder = minSortOrder != null ? minSortOrder - 1 : 0
}
await db.insert(workflow).values({

View File

@@ -0,0 +1,177 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockLogger, queryClient, useFolderStoreMock, useWorkflowRegistryMock } = vi.hoisted(() => ({
mockLogger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
queryClient: {
cancelQueries: vi.fn().mockResolvedValue(undefined),
invalidateQueries: vi.fn().mockResolvedValue(undefined),
},
useFolderStoreMock: Object.assign(vi.fn(), {
getState: vi.fn(),
setState: vi.fn(),
}),
useWorkflowRegistryMock: Object.assign(vi.fn(), {
getState: vi.fn(),
setState: vi.fn(),
}),
}))
let folderState: {
folders: Record<string, any>
}
let workflowRegistryState: {
workflows: Record<string, any>
}
vi.mock('@sim/logger', () => ({
createLogger: vi.fn(() => mockLogger),
}))
vi.mock('@tanstack/react-query', () => ({
keepPreviousData: {},
useQuery: vi.fn(),
useQueryClient: vi.fn(() => queryClient),
useMutation: vi.fn((options) => options),
}))
vi.mock('@/stores/folders/store', () => ({
useFolderStore: useFolderStoreMock,
}))
vi.mock('@/stores/workflows/registry/store', () => ({
useWorkflowRegistry: useWorkflowRegistryMock,
}))
vi.mock('@/hooks/queries/workflows', () => ({
workflowKeys: {
list: (workspaceId: string | undefined) => ['workflows', 'list', workspaceId ?? ''],
},
}))
import { useCreateFolder, useDuplicateFolderMutation } from '@/hooks/queries/folders'
function getOptimisticFolderByName(name: string) {
return Object.values(folderState.folders).find((folder: any) => folder.name === name) as
| { sortOrder: number }
| undefined
}
describe('folder optimistic top insertion ordering', () => {
beforeEach(() => {
vi.clearAllMocks()
useFolderStoreMock.getState.mockImplementation(() => folderState)
useFolderStoreMock.setState.mockImplementation((updater: any) => {
if (typeof updater === 'function') {
const next = updater(folderState)
if (next) {
folderState = { ...folderState, ...next }
}
return
}
folderState = { ...folderState, ...updater }
})
useWorkflowRegistryMock.getState.mockImplementation(() => workflowRegistryState)
folderState = {
folders: {
'folder-parent-match': {
id: 'folder-parent-match',
name: 'Existing sibling folder',
userId: 'user-1',
workspaceId: 'ws-1',
parentId: 'parent-1',
color: '#808080',
isExpanded: false,
sortOrder: 5,
createdAt: new Date(),
updatedAt: new Date(),
},
'folder-other-parent': {
id: 'folder-other-parent',
name: 'Other parent folder',
userId: 'user-1',
workspaceId: 'ws-1',
parentId: 'parent-2',
color: '#808080',
isExpanded: false,
sortOrder: -100,
createdAt: new Date(),
updatedAt: new Date(),
},
},
}
workflowRegistryState = {
workflows: {
'workflow-parent-match': {
id: 'workflow-parent-match',
name: 'Existing sibling workflow',
workspaceId: 'ws-1',
folderId: 'parent-1',
sortOrder: 2,
},
'workflow-other-parent': {
id: 'workflow-other-parent',
name: 'Other parent workflow',
workspaceId: 'ws-1',
folderId: 'parent-2',
sortOrder: -50,
},
},
}
})
it('creates folders at top of mixed non-root siblings', async () => {
const mutation = useCreateFolder()
await mutation.onMutate({
workspaceId: 'ws-1',
name: 'New child folder',
parentId: 'parent-1',
})
const optimisticFolder = getOptimisticFolderByName('New child folder')
expect(optimisticFolder).toBeDefined()
expect(optimisticFolder?.sortOrder).toBe(1)
})
it('duplicates folders at top of mixed non-root siblings', async () => {
const mutation = useDuplicateFolderMutation()
await mutation.onMutate({
workspaceId: 'ws-1',
id: 'folder-parent-match',
name: 'Duplicated child folder',
parentId: 'parent-1',
})
const optimisticFolder = getOptimisticFolderByName('Duplicated child folder')
expect(optimisticFolder).toBeDefined()
expect(optimisticFolder?.sortOrder).toBe(1)
})
it('uses source parent scope when duplicate parentId is undefined', async () => {
const mutation = useDuplicateFolderMutation()
await mutation.onMutate({
workspaceId: 'ws-1',
id: 'folder-parent-match',
name: 'Duplicated with inherited parent',
// parentId intentionally omitted to mirror duplicate fallback behavior
})
const optimisticFolder = getOptimisticFolderByName('Duplicated with inherited parent') as
| { parentId: string | null; sortOrder: number }
| undefined
expect(optimisticFolder).toBeDefined()
expect(optimisticFolder?.parentId).toBe('parent-1')
expect(optimisticFolder?.sortOrder).toBe(1)
})
})

View File

@@ -5,9 +5,11 @@ import {
createOptimisticMutationHandlers,
generateTempId,
} from '@/hooks/queries/utils/optimistic-mutation'
import { getTopInsertionSortOrder } from '@/hooks/queries/utils/top-insertion-sort-order'
import { workflowKeys } from '@/hooks/queries/workflows'
import { useFolderStore } from '@/stores/folders/store'
import type { WorkflowFolder } from '@/stores/folders/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('FolderQueries')
@@ -133,40 +135,35 @@ function createFolderMutationHandlers<TVariables extends { workspaceId: string }
})
}
/**
* Calculates the next sort order for a folder in a given parent
*/
function getNextSortOrder(
folders: Record<string, WorkflowFolder>,
workspaceId: string,
parentId: string | null | undefined
): number {
const siblingFolders = Object.values(folders).filter(
(f) => f.workspaceId === workspaceId && f.parentId === (parentId || null)
)
return siblingFolders.reduce((max, f) => Math.max(max, f.sortOrder), -1) + 1
}
export function useCreateFolder() {
const queryClient = useQueryClient()
const handlers = createFolderMutationHandlers<CreateFolderVariables>(
queryClient,
'CreateFolder',
(variables, tempId, previousFolders) => ({
id: tempId,
name: variables.name,
userId: '',
workspaceId: variables.workspaceId,
parentId: variables.parentId || null,
color: variables.color || '#808080',
isExpanded: false,
sortOrder:
variables.sortOrder ??
getNextSortOrder(previousFolders, variables.workspaceId, variables.parentId),
createdAt: new Date(),
updatedAt: new Date(),
})
(variables, tempId, previousFolders) => {
const currentWorkflows = useWorkflowRegistry.getState().workflows
return {
id: tempId,
name: variables.name,
userId: '',
workspaceId: variables.workspaceId,
parentId: variables.parentId || null,
color: variables.color || '#808080',
isExpanded: false,
sortOrder:
variables.sortOrder ??
getTopInsertionSortOrder(
currentWorkflows,
previousFolders,
variables.workspaceId,
variables.parentId
),
createdAt: new Date(),
updatedAt: new Date(),
}
}
)
return useMutation({
@@ -242,17 +239,25 @@ export function useDuplicateFolderMutation() {
queryClient,
'DuplicateFolder',
(variables, tempId, previousFolders) => {
const currentWorkflows = useWorkflowRegistry.getState().workflows
// Get source folder info if available
const sourceFolder = previousFolders[variables.id]
const targetParentId = variables.parentId ?? sourceFolder?.parentId ?? null
return {
id: tempId,
name: variables.name,
userId: sourceFolder?.userId || '',
workspaceId: variables.workspaceId,
parentId: variables.parentId ?? sourceFolder?.parentId ?? null,
parentId: targetParentId,
color: variables.color || sourceFolder?.color || '#808080',
isExpanded: false,
sortOrder: getNextSortOrder(previousFolders, variables.workspaceId, variables.parentId),
sortOrder: getTopInsertionSortOrder(
currentWorkflows,
previousFolders,
variables.workspaceId,
targetParentId
),
createdAt: new Date(),
updatedAt: new Date(),
}

View File

@@ -0,0 +1,44 @@
interface SortableWorkflow {
workspaceId?: string
folderId?: string | null
sortOrder?: number
}
interface SortableFolder {
workspaceId?: string
parentId?: string | null
sortOrder: number
}
/**
* Calculates the insertion sort order that places a new item at the top of a
* mixed list of folders and workflows within the same parent scope.
*/
export function getTopInsertionSortOrder(
workflows: Record<string, SortableWorkflow>,
folders: Record<string, SortableFolder>,
workspaceId: string,
parentId: string | null | undefined
): number {
const normalizedParentId = parentId ?? null
const siblingWorkflows = Object.values(workflows).filter(
(workflow) =>
workflow.workspaceId === workspaceId && (workflow.folderId ?? null) === normalizedParentId
)
const siblingFolders = Object.values(folders).filter(
(folder) =>
folder.workspaceId === workspaceId && (folder.parentId ?? null) === normalizedParentId
)
const siblingOrders = [
...siblingWorkflows.map((workflow) => workflow.sortOrder ?? 0),
...siblingFolders.map((folder) => folder.sortOrder),
]
if (siblingOrders.length === 0) {
return 0
}
return Math.min(...siblingOrders) - 1
}

View File

@@ -8,6 +8,8 @@ import {
createOptimisticMutationHandlers,
generateTempId,
} from '@/hooks/queries/utils/optimistic-mutation'
import { getTopInsertionSortOrder } from '@/hooks/queries/utils/top-insertion-sort-order'
import { useFolderStore } from '@/stores/folders/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
import { generateCreativeWorkflowName } from '@/stores/workflows/registry/utils'
@@ -223,11 +225,13 @@ export function useCreateWorkflow() {
sortOrder = variables.sortOrder
} else {
const currentWorkflows = useWorkflowRegistry.getState().workflows
const targetFolderId = variables.folderId || null
const workflowsInFolder = Object.values(currentWorkflows).filter(
(w) => w.folderId === targetFolderId
const currentFolders = useFolderStore.getState().folders
sortOrder = getTopInsertionSortOrder(
currentWorkflows,
currentFolders,
variables.workspaceId,
variables.folderId
)
sortOrder = workflowsInFolder.reduce((min, w) => Math.min(min, w.sortOrder ?? 0), 1) - 1
}
return {
@@ -323,11 +327,8 @@ export function useDuplicateWorkflowMutation() {
'DuplicateWorkflow',
(variables, tempId) => {
const currentWorkflows = useWorkflowRegistry.getState().workflows
const targetFolderId = variables.folderId || null
const workflowsInFolder = Object.values(currentWorkflows).filter(
(w) => w.folderId === targetFolderId
)
const minSortOrder = workflowsInFolder.reduce((min, w) => Math.min(min, w.sortOrder ?? 0), 1)
const currentFolders = useFolderStore.getState().folders
const targetFolderId = variables.folderId ?? null
return {
id: tempId,
@@ -338,7 +339,12 @@ export function useDuplicateWorkflowMutation() {
color: variables.color,
workspaceId: variables.workspaceId,
folderId: targetFolderId,
sortOrder: minSortOrder - 1,
sortOrder: getTopInsertionSortOrder(
currentWorkflows,
currentFolders,
variables.workspaceId,
targetFolderId
),
}
}
)

View File

@@ -0,0 +1,197 @@
/**
* @vitest-environment node
*/
import { mockConsoleLogger, setupCommonApiMocks } from '@sim/testing'
import { drizzleOrmMock } from '@sim/testing/mocks'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockAuthorizeWorkflowByWorkspacePermission = vi.fn()
const mockGetUserEntityPermissions = vi.fn()
const { mockDb } = vi.hoisted(() => ({
mockDb: {
transaction: vi.fn(),
},
}))
vi.mock('drizzle-orm', () => ({
...drizzleOrmMock,
min: vi.fn((field) => ({ type: 'min', field })),
}))
vi.mock('@/lib/workflows/utils', () => ({
authorizeWorkflowByWorkspacePermission: (...args: unknown[]) =>
mockAuthorizeWorkflowByWorkspacePermission(...args),
}))
vi.mock('@/lib/workspaces/permissions/utils', () => ({
getUserEntityPermissions: (...args: unknown[]) => mockGetUserEntityPermissions(...args),
}))
vi.mock('@sim/db/schema', () => ({
workflow: {
id: 'id',
workspaceId: 'workspaceId',
folderId: 'folderId',
sortOrder: 'sortOrder',
variables: 'variables',
},
workflowFolder: {
workspaceId: 'workspaceId',
parentId: 'parentId',
sortOrder: 'sortOrder',
},
workflowBlocks: {
workflowId: 'workflowId',
},
workflowEdges: {
workflowId: 'workflowId',
},
workflowSubflows: {
workflowId: 'workflowId',
},
}))
vi.mock('@sim/db', () => ({
db: mockDb,
}))
import { duplicateWorkflow } from './duplicate'
function createMockTx(
selectResults: unknown[],
onWorkflowInsert?: (values: Record<string, unknown>) => void
) {
let selectCallCount = 0
const select = vi.fn().mockImplementation(() => ({
from: vi.fn().mockReturnValue({
where: vi.fn().mockImplementation(() => {
const result = selectResults[selectCallCount++] ?? []
if (selectCallCount === 1) {
return {
limit: vi.fn().mockResolvedValue(result),
}
}
return Promise.resolve(result)
}),
}),
}))
const insert = vi.fn().mockReturnValue({
values: vi.fn().mockImplementation((values: Record<string, unknown>) => {
onWorkflowInsert?.(values)
return Promise.resolve(undefined)
}),
})
const update = vi.fn().mockReturnValue({
set: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue(undefined),
}),
})
return {
select,
insert,
update,
}
}
describe('duplicateWorkflow ordering', () => {
beforeEach(() => {
setupCommonApiMocks()
mockConsoleLogger()
vi.clearAllMocks()
vi.stubGlobal('crypto', {
randomUUID: vi.fn().mockReturnValue('new-workflow-id'),
})
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ allowed: true })
mockGetUserEntityPermissions.mockResolvedValue('write')
})
it('uses mixed-sibling top insertion sort order', async () => {
let insertedWorkflowValues: Record<string, unknown> | null = null
const tx = createMockTx(
[
[
{
id: 'source-workflow-id',
workspaceId: 'workspace-123',
folderId: null,
description: 'source',
color: '#000000',
variables: {},
},
],
[{ minOrder: 5 }],
[{ minOrder: 2 }],
[],
[],
[],
],
(values) => {
insertedWorkflowValues = values
}
)
mockDb.transaction.mockImplementation(async (callback: (txArg: unknown) => Promise<unknown>) =>
callback(tx)
)
const result = await duplicateWorkflow({
sourceWorkflowId: 'source-workflow-id',
userId: 'user-123',
name: 'Duplicated',
workspaceId: 'workspace-123',
folderId: null,
requestId: 'req-1',
})
expect(result.sortOrder).toBe(1)
expect(insertedWorkflowValues?.sortOrder).toBe(1)
})
it('defaults to sortOrder 0 when target has no siblings', async () => {
let insertedWorkflowValues: Record<string, unknown> | null = null
const tx = createMockTx(
[
[
{
id: 'source-workflow-id',
workspaceId: 'workspace-123',
folderId: null,
description: 'source',
color: '#000000',
variables: {},
},
],
[],
[],
[],
[],
[],
],
(values) => {
insertedWorkflowValues = values
}
)
mockDb.transaction.mockImplementation(async (callback: (txArg: unknown) => Promise<unknown>) =>
callback(tx)
)
const result = await duplicateWorkflow({
sourceWorkflowId: 'source-workflow-id',
userId: 'user-123',
name: 'Duplicated',
workspaceId: 'workspace-123',
folderId: null,
requestId: 'req-2',
})
expect(result.sortOrder).toBe(0)
expect(insertedWorkflowValues?.sortOrder).toBe(0)
})
})

View File

@@ -1,5 +1,11 @@
import { db } from '@sim/db'
import { workflow, workflowBlocks, workflowEdges, workflowSubflows } from '@sim/db/schema'
import {
workflow,
workflowBlocks,
workflowEdges,
workflowFolder,
workflowSubflows,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull, min } from 'drizzle-orm'
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
@@ -132,15 +138,31 @@ export async function duplicateWorkflow(
throw new Error('Write or admin access required for target workspace')
}
const targetFolderId = folderId !== undefined ? folderId : source.folderId
const folderCondition = targetFolderId
const workflowParentCondition = targetFolderId
? eq(workflow.folderId, targetFolderId)
: isNull(workflow.folderId)
const folderParentCondition = targetFolderId
? eq(workflowFolder.parentId, targetFolderId)
: isNull(workflowFolder.parentId)
const [minResult] = await tx
.select({ minOrder: min(workflow.sortOrder) })
.from(workflow)
.where(and(eq(workflow.workspaceId, targetWorkspaceId), folderCondition))
const sortOrder = (minResult?.minOrder ?? 1) - 1
const [[workflowMinResult], [folderMinResult]] = await Promise.all([
tx
.select({ minOrder: min(workflow.sortOrder) })
.from(workflow)
.where(and(eq(workflow.workspaceId, targetWorkspaceId), workflowParentCondition)),
tx
.select({ minOrder: min(workflowFolder.sortOrder) })
.from(workflowFolder)
.where(and(eq(workflowFolder.workspaceId, targetWorkspaceId), folderParentCondition)),
])
const minSortOrder = [workflowMinResult?.minOrder, folderMinResult?.minOrder].reduce<
number | null
>((currentMin, candidate) => {
if (candidate == null) return currentMin
if (currentMin == null) return candidate
return Math.min(currentMin, candidate)
}, null)
const sortOrder = minSortOrder != null ? minSortOrder - 1 : 0
// Mapping from old variable IDs to new variable IDs (populated during variable duplication)
const varIdMapping = new Map<string, string>()

View File

@@ -13,7 +13,7 @@
"glob": "13.0.0",
"husky": "9.1.7",
"lint-staged": "16.0.0",
"turbo": "2.8.9",
"turbo": "2.8.10",
},
},
"apps/docs": {
@@ -3437,19 +3437,19 @@
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
"turbo": ["turbo@2.8.9", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.9", "turbo-darwin-arm64": "2.8.9", "turbo-linux-64": "2.8.9", "turbo-linux-arm64": "2.8.9", "turbo-windows-64": "2.8.9", "turbo-windows-arm64": "2.8.9" }, "bin": { "turbo": "bin/turbo" } }, "sha512-G+Mq8VVQAlpz/0HTsxiNNk/xywaHGl+dk1oiBREgOEVCCDjXInDlONWUn5srRnC9s5tdHTFD1bx1N19eR4hI+g=="],
"turbo": ["turbo@2.8.10", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.10", "turbo-darwin-arm64": "2.8.10", "turbo-linux-64": "2.8.10", "turbo-linux-arm64": "2.8.10", "turbo-windows-64": "2.8.10", "turbo-windows-arm64": "2.8.10" }, "bin": { "turbo": "bin/turbo" } }, "sha512-OxbzDES66+x7nnKGg2MwBA1ypVsZoDTLHpeaP4giyiHSixbsiTaMyeJqbEyvBdp5Cm28fc+8GG6RdQtic0ijwQ=="],
"turbo-darwin-64": ["turbo-darwin-64@2.8.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-KnCw1ZI9KTnEAhdI9avZrnZ/z4wsM++flMA1w8s8PKOqi5daGpFV36qoPafg4S8TmYMe52JPWEoFr0L+lQ5JIw=="],
"turbo-darwin-64": ["turbo-darwin-64@2.8.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-A03fXh+B7S8mL3PbdhTd+0UsaGrhfyPkODvzBDpKRY7bbeac4MDFpJ7I+Slf2oSkCEeSvHKR7Z4U71uKRUfX7g=="],
"turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CbD5Y2NKJKBXTOZ7z7Cc7vGlFPZkYjApA7ri9lH4iFwKV1X7MoZswh9gyRLetXYWImVX1BqIvP8KftulJg/wIA=="],
"turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-sidzowgWL3s5xCHLeqwC9M3s9M0i16W1nuQF3Mc7fPHpZ+YPohvcbVFBB2uoRRHYZg6yBnwD4gyUHKTeXfwtXA=="],
"turbo-linux-64": ["turbo-linux-64@2.8.9", "", { "os": "linux", "cpu": "x64" }, "sha512-OXC9HdCtsHvyH+5KUoH8ds+p5WU13vdif0OPbsFzZca4cUXMwKA3HWwUuCgQetk0iAE4cscXpi/t8A263n3VTg=="],
"turbo-linux-64": ["turbo-linux-64@2.8.10", "", { "os": "linux", "cpu": "x64" }, "sha512-YK9vcpL3TVtqonB021XwgaQhY9hJJbKKUhLv16osxV0HkcQASQWUqR56yMge7puh6nxU67rQlTq1b7ksR1T3KA=="],
"turbo-linux-arm64": ["turbo-linux-arm64@2.8.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-yI5n8jNXiFA6+CxnXG0gO7h5ZF1+19K8uO3/kXPQmyl37AdiA7ehKJQOvf9OPAnmkGDHcF2HSCPltabERNRmug=="],
"turbo-linux-arm64": ["turbo-linux-arm64@2.8.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-3+j2tL0sG95iBJTm+6J8/45JsETQABPqtFyYjVjBbi6eVGdtNTiBmHNKrbvXRlQ3ZbUG75bKLaSSDHSEEN+btQ=="],
"turbo-windows-64": ["turbo-windows-64@2.8.9", "", { "os": "win32", "cpu": "x64" }, "sha512-/OztzeGftJAg258M/9vK2ZCkUKUzqrWXJIikiD2pm8TlqHcIYUmepDbyZSDfOiUjMy6NzrLFahpNLnY7b5vNgg=="],
"turbo-windows-64": ["turbo-windows-64@2.8.10", "", { "os": "win32", "cpu": "x64" }, "sha512-hdeF5qmVY/NFgiucf8FW0CWJWtyT2QPm5mIsX0W1DXAVzqKVXGq+Zf+dg4EUngAFKjDzoBeN6ec2Fhajwfztkw=="],
"turbo-windows-arm64": ["turbo-windows-arm64@2.8.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-xZ2VTwVTjIqpFZKN4UBxDHCPM3oJ2J5cpRzCBSmRpJ/Pn33wpiYjs+9FB2E03svKaD04/lSSLlEUej0UYsugfg=="],
"turbo-windows-arm64": ["turbo-windows-arm64@2.8.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-QGdr/Q8LWmj+ITMkSvfiz2glf0d7JG0oXVzGL3jxkGqiBI1zXFj20oqVY0qWi+112LO9SVrYdpHS0E/oGFrMbQ=="],
"tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="],

View File

@@ -42,7 +42,7 @@
"glob": "13.0.0",
"husky": "9.1.7",
"lint-staged": "16.0.0",
"turbo": "2.8.9"
"turbo": "2.8.10"
},
"lint-staged": {
"*.{js,jsx,ts,tsx,json,css,scss}": [