mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
fix(sidebar): use client-generated UUIDs for stable optimistic updates (#3439)
* fix(sidebar): use client-generated UUIDs for stable optimistic updates * fix(folders): use zod schema validation for folder create API Replace inline UUID regex with zod schema validation for consistency with other API routes. Update test expectations accordingly. * fix(sidebar): add client UUID to single workflow duplicate hook The useDuplicateWorkflow hook was missing newId: crypto.randomUUID(), causing the same temp-ID-swap issue for single workflow duplication from the context menu. * fix(folders): avoid unnecessary Set re-creation in replaceOptimisticEntry Only create new expandedFolders/selectedFolders Sets when tempId differs from data.id. In the common happy path (client-generated UUIDs), this avoids unnecessary Zustand state reference changes and re-renders.
This commit is contained in:
@@ -17,6 +17,7 @@ const DuplicateRequestSchema = z.object({
|
||||
workspaceId: z.string().optional(),
|
||||
parentId: z.string().nullable().optional(),
|
||||
color: z.string().optional(),
|
||||
newId: z.string().uuid().optional(),
|
||||
})
|
||||
|
||||
// POST /api/folders/[id]/duplicate - Duplicate a folder with all its child folders and workflows
|
||||
@@ -33,7 +34,13 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
|
||||
try {
|
||||
const body = await req.json()
|
||||
const { name, workspaceId, parentId, color } = DuplicateRequestSchema.parse(body)
|
||||
const {
|
||||
name,
|
||||
workspaceId,
|
||||
parentId,
|
||||
color,
|
||||
newId: clientNewId,
|
||||
} = DuplicateRequestSchema.parse(body)
|
||||
|
||||
logger.info(`[${requestId}] Duplicating folder ${sourceFolderId} for user ${session.user.id}`)
|
||||
|
||||
@@ -60,7 +67,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
const targetWorkspaceId = workspaceId || sourceFolder.workspaceId
|
||||
|
||||
const { newFolderId, folderMapping } = await db.transaction(async (tx) => {
|
||||
const newFolderId = crypto.randomUUID()
|
||||
const newFolderId = clientNewId || crypto.randomUUID()
|
||||
const now = new Date()
|
||||
const targetParentId = parentId ?? sourceFolder.parentId
|
||||
|
||||
|
||||
@@ -455,7 +455,7 @@ describe('Folders API Route', () => {
|
||||
expect(response.status).toBe(400)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('error', 'Name and workspace ID are required')
|
||||
expect(data).toHaveProperty('error', 'Invalid request data')
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -3,12 +3,22 @@ import { workflow, workflowFolder } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, asc, 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'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('FoldersAPI')
|
||||
|
||||
const CreateFolderSchema = z.object({
|
||||
id: z.string().uuid().optional(),
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
workspaceId: z.string().min(1, 'Workspace ID is required'),
|
||||
parentId: z.string().optional(),
|
||||
color: z.string().optional(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
})
|
||||
|
||||
// GET - Fetch folders for a workspace
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
@@ -59,13 +69,15 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { name, workspaceId, parentId, color, sortOrder: providedSortOrder } = body
|
||||
const {
|
||||
id: clientId,
|
||||
name,
|
||||
workspaceId,
|
||||
parentId,
|
||||
color,
|
||||
sortOrder: providedSortOrder,
|
||||
} = CreateFolderSchema.parse(body)
|
||||
|
||||
if (!name || !workspaceId) {
|
||||
return NextResponse.json({ error: 'Name and workspace ID are required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Check if user has workspace permissions (at least 'write' access to create folders)
|
||||
const workspacePermission = await getUserEntityPermissions(
|
||||
session.user.id,
|
||||
'workspace',
|
||||
@@ -79,8 +91,7 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// Generate a new ID
|
||||
const id = crypto.randomUUID()
|
||||
const id = clientId || crypto.randomUUID()
|
||||
|
||||
const newFolder = await db.transaction(async (tx) => {
|
||||
let sortOrder: number
|
||||
@@ -150,6 +161,14 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
return NextResponse.json({ folder: newFolder })
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn('Invalid folder creation data', { errors: error.errors })
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request data', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error('Error creating folder:', { error })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ const DuplicateRequestSchema = z.object({
|
||||
color: z.string().optional(),
|
||||
workspaceId: z.string().optional(),
|
||||
folderId: z.string().nullable().optional(),
|
||||
newId: z.string().uuid().optional(),
|
||||
})
|
||||
|
||||
// POST /api/workflows/[id]/duplicate - Duplicate a workflow with all its blocks, edges, and subflows
|
||||
@@ -32,7 +33,8 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
|
||||
try {
|
||||
const body = await req.json()
|
||||
const { name, description, color, workspaceId, folderId } = DuplicateRequestSchema.parse(body)
|
||||
const { name, description, color, workspaceId, folderId, newId } =
|
||||
DuplicateRequestSchema.parse(body)
|
||||
|
||||
logger.info(`[${requestId}] Duplicating workflow ${sourceWorkflowId} for user ${userId}`)
|
||||
|
||||
@@ -45,6 +47,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
workspaceId,
|
||||
folderId,
|
||||
requestId,
|
||||
newWorkflowId: newId,
|
||||
})
|
||||
|
||||
try {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
const logger = createLogger('WorkflowAPI')
|
||||
|
||||
const CreateWorkflowSchema = z.object({
|
||||
id: z.string().uuid().optional(),
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
description: z.string().optional().default(''),
|
||||
color: z.string().optional().default('#3972F6'),
|
||||
@@ -109,6 +110,7 @@ export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json()
|
||||
const {
|
||||
id: clientId,
|
||||
name,
|
||||
description,
|
||||
color,
|
||||
@@ -140,7 +142,7 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const workflowId = crypto.randomUUID()
|
||||
const workflowId = clientId || crypto.randomUUID()
|
||||
const now = new Date()
|
||||
|
||||
logger.info(`[${requestId}] Creating workflow ${workflowId} for user ${userId}`)
|
||||
|
||||
@@ -144,6 +144,7 @@ export function FolderItem({
|
||||
folderId: folder.id,
|
||||
name,
|
||||
color,
|
||||
id: crypto.randomUUID(),
|
||||
})
|
||||
|
||||
if (result.id) {
|
||||
@@ -164,6 +165,7 @@ export function FolderItem({
|
||||
workspaceId,
|
||||
name: 'New Folder',
|
||||
parentId: folder.id,
|
||||
id: crypto.randomUUID(),
|
||||
})
|
||||
if (result.id) {
|
||||
expandFolder()
|
||||
|
||||
@@ -27,7 +27,11 @@ export function useFolderOperations({ workspaceId }: UseFolderOperationsProps) {
|
||||
|
||||
try {
|
||||
const folderName = await generateFolderName(workspaceId)
|
||||
const folder = await createFolderMutation.mutateAsync({ name: folderName, workspaceId })
|
||||
const folder = await createFolderMutation.mutateAsync({
|
||||
name: folderName,
|
||||
workspaceId,
|
||||
id: crypto.randomUUID(),
|
||||
})
|
||||
logger.info(`Created folder: ${folderName}`)
|
||||
return folder.id
|
||||
} catch (error) {
|
||||
|
||||
@@ -42,6 +42,7 @@ export function useWorkflowOperations({ workspaceId }: UseWorkflowOperationsProp
|
||||
workspaceId,
|
||||
name,
|
||||
color,
|
||||
id: crypto.randomUUID(),
|
||||
})
|
||||
|
||||
if (result.id) {
|
||||
|
||||
@@ -77,6 +77,7 @@ export function useDuplicateFolder({ workspaceId, folderIds, onSuccess }: UseDup
|
||||
name: duplicateName,
|
||||
parentId: folder.parentId,
|
||||
color: folder.color,
|
||||
newId: crypto.randomUUID(),
|
||||
})
|
||||
const newFolderId = result?.id
|
||||
if (newFolderId) {
|
||||
|
||||
@@ -88,6 +88,7 @@ export function useDuplicateSelection({ workspaceId, onSuccess }: UseDuplicateSe
|
||||
name: duplicateName,
|
||||
parentId: folder.parentId,
|
||||
color: folder.color,
|
||||
newId: crypto.randomUUID(),
|
||||
})
|
||||
|
||||
if (result?.id) {
|
||||
@@ -109,6 +110,7 @@ export function useDuplicateSelection({ workspaceId, onSuccess }: UseDuplicateSe
|
||||
description: workflow.description,
|
||||
color: getNextWorkflowColor(),
|
||||
folderId: workflow.folderId,
|
||||
newId: crypto.randomUUID(),
|
||||
})
|
||||
|
||||
duplicatedWorkflowIds.push(result.id)
|
||||
|
||||
@@ -77,6 +77,7 @@ export function useDuplicateWorkflow({ workspaceId, onSuccess }: UseDuplicateWor
|
||||
description: sourceWorkflow.description,
|
||||
color: getNextWorkflowColor(),
|
||||
folderId: sourceWorkflow.folderId,
|
||||
newId: crypto.randomUUID(),
|
||||
})
|
||||
|
||||
duplicatedIds.push(result.id)
|
||||
|
||||
@@ -71,6 +71,7 @@ interface CreateFolderVariables {
|
||||
parentId?: string
|
||||
color?: string
|
||||
sortOrder?: number
|
||||
id?: string
|
||||
}
|
||||
|
||||
interface UpdateFolderVariables {
|
||||
@@ -90,6 +91,7 @@ interface DuplicateFolderVariables {
|
||||
name: string
|
||||
parentId?: string | null
|
||||
color?: string
|
||||
newId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,13 +104,14 @@ function createFolderMutationHandlers<TVariables extends { workspaceId: string }
|
||||
variables: TVariables,
|
||||
tempId: string,
|
||||
previousFolders: Record<string, WorkflowFolder>
|
||||
) => WorkflowFolder
|
||||
) => WorkflowFolder,
|
||||
customGenerateTempId?: (variables: TVariables) => string
|
||||
) {
|
||||
return createOptimisticMutationHandlers<WorkflowFolder, TVariables, WorkflowFolder>(queryClient, {
|
||||
name,
|
||||
getQueryKey: (variables) => folderKeys.list(variables.workspaceId),
|
||||
getSnapshot: () => ({ ...useFolderStore.getState().folders }),
|
||||
generateTempId: () => generateTempId('temp-folder'),
|
||||
generateTempId: customGenerateTempId ?? (() => generateTempId('temp-folder')),
|
||||
createOptimisticItem: (variables, tempId) => {
|
||||
const previousFolders = useFolderStore.getState().folders
|
||||
return createOptimisticFolder(variables, tempId, previousFolders)
|
||||
@@ -121,12 +124,36 @@ function createFolderMutationHandlers<TVariables extends { workspaceId: string }
|
||||
replaceOptimisticEntry: (tempId, data) => {
|
||||
useFolderStore.setState((state) => {
|
||||
const { [tempId]: _, ...remainingFolders } = state.folders
|
||||
return {
|
||||
|
||||
const update: Record<string, unknown> = {
|
||||
folders: {
|
||||
...remainingFolders,
|
||||
[data.id]: data,
|
||||
},
|
||||
}
|
||||
|
||||
if (tempId !== data.id) {
|
||||
const expandedFolders = new Set(state.expandedFolders)
|
||||
const selectedFolders = new Set(state.selectedFolders)
|
||||
|
||||
if (expandedFolders.has(tempId)) {
|
||||
expandedFolders.delete(tempId)
|
||||
expandedFolders.add(data.id)
|
||||
}
|
||||
if (selectedFolders.has(tempId)) {
|
||||
selectedFolders.delete(tempId)
|
||||
selectedFolders.add(data.id)
|
||||
}
|
||||
|
||||
update.expandedFolders = expandedFolders
|
||||
update.selectedFolders = selectedFolders
|
||||
|
||||
if (state.lastSelectedFolderId === tempId) {
|
||||
update.lastSelectedFolderId = data.id
|
||||
}
|
||||
}
|
||||
|
||||
return update
|
||||
})
|
||||
},
|
||||
rollback: (snapshot) => {
|
||||
@@ -163,7 +190,8 @@ export function useCreateFolder() {
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
}
|
||||
},
|
||||
(variables) => variables.id ?? crypto.randomUUID()
|
||||
)
|
||||
|
||||
return useMutation({
|
||||
@@ -241,7 +269,6 @@ export function useDuplicateFolderMutation() {
|
||||
(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 {
|
||||
@@ -261,7 +288,8 @@ export function useDuplicateFolderMutation() {
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
}
|
||||
},
|
||||
(variables) => variables.newId ?? crypto.randomUUID()
|
||||
)
|
||||
|
||||
return useMutation({
|
||||
@@ -271,6 +299,7 @@ export function useDuplicateFolderMutation() {
|
||||
name,
|
||||
parentId,
|
||||
color,
|
||||
newId,
|
||||
}: DuplicateFolderVariables): Promise<WorkflowFolder> => {
|
||||
const response = await fetch(`/api/folders/${id}/duplicate`, {
|
||||
method: 'POST',
|
||||
@@ -280,6 +309,7 @@ export function useDuplicateFolderMutation() {
|
||||
name,
|
||||
parentId: parentId ?? null,
|
||||
color,
|
||||
newId,
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ export interface OptimisticMutationConfig<TData, TVariables, TItem, TContext> {
|
||||
name: string
|
||||
getQueryKey: (variables: TVariables) => readonly unknown[]
|
||||
getSnapshot: () => Record<string, TItem>
|
||||
generateTempId: () => string
|
||||
generateTempId: (variables: TVariables) => string
|
||||
createOptimisticItem: (variables: TVariables, tempId: string) => TItem
|
||||
applyOptimisticUpdate: (tempId: string, item: TItem) => void
|
||||
replaceOptimisticEntry: (tempId: string, data: TData) => void
|
||||
@@ -41,7 +41,7 @@ export function createOptimisticMutationHandlers<TData, TVariables, TItem>(
|
||||
const queryKey = getQueryKey(variables)
|
||||
await queryClient.cancelQueries({ queryKey })
|
||||
const previousState = getSnapshot()
|
||||
const tempId = generateTempId()
|
||||
const tempId = generateTempId(variables)
|
||||
const optimisticItem = createOptimisticItem(variables, tempId)
|
||||
applyOptimisticUpdate(tempId, optimisticItem)
|
||||
logger.info(`[${name}] Added optimistic entry: ${tempId}`)
|
||||
|
||||
@@ -128,6 +128,7 @@ interface CreateWorkflowVariables {
|
||||
color?: string
|
||||
folderId?: string | null
|
||||
sortOrder?: number
|
||||
id?: string
|
||||
}
|
||||
|
||||
interface CreateWorkflowResult {
|
||||
@@ -147,6 +148,7 @@ interface DuplicateWorkflowVariables {
|
||||
description?: string
|
||||
color: string
|
||||
folderId?: string | null
|
||||
newId?: string
|
||||
}
|
||||
|
||||
interface DuplicateWorkflowResult {
|
||||
@@ -168,7 +170,8 @@ interface DuplicateWorkflowResult {
|
||||
function createWorkflowMutationHandlers<TVariables extends { workspaceId: string }>(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
name: string,
|
||||
createOptimisticWorkflow: (variables: TVariables, tempId: string) => WorkflowMetadata
|
||||
createOptimisticWorkflow: (variables: TVariables, tempId: string) => WorkflowMetadata,
|
||||
customGenerateTempId?: (variables: TVariables) => string
|
||||
) {
|
||||
return createOptimisticMutationHandlers<
|
||||
CreateWorkflowResult | DuplicateWorkflowResult,
|
||||
@@ -178,7 +181,7 @@ function createWorkflowMutationHandlers<TVariables extends { workspaceId: string
|
||||
name,
|
||||
getQueryKey: (variables) => workflowKeys.list(variables.workspaceId),
|
||||
getSnapshot: () => ({ ...useWorkflowRegistry.getState().workflows }),
|
||||
generateTempId: () => generateTempId('temp-workflow'),
|
||||
generateTempId: customGenerateTempId ?? (() => generateTempId('temp-workflow')),
|
||||
createOptimisticItem: createOptimisticWorkflow,
|
||||
applyOptimisticUpdate: (tempId, item) => {
|
||||
useWorkflowRegistry.setState((state) => ({
|
||||
@@ -206,6 +209,17 @@ function createWorkflowMutationHandlers<TVariables extends { workspaceId: string
|
||||
error: null,
|
||||
}
|
||||
})
|
||||
|
||||
if (tempId !== data.id) {
|
||||
useFolderStore.setState((state) => {
|
||||
const selectedWorkflows = new Set(state.selectedWorkflows)
|
||||
if (selectedWorkflows.has(tempId)) {
|
||||
selectedWorkflows.delete(tempId)
|
||||
selectedWorkflows.add(data.id)
|
||||
}
|
||||
return { selectedWorkflows }
|
||||
})
|
||||
}
|
||||
},
|
||||
rollback: (snapshot) => {
|
||||
useWorkflowRegistry.setState({ workflows: snapshot })
|
||||
@@ -245,12 +259,13 @@ export function useCreateWorkflow() {
|
||||
folderId: variables.folderId || null,
|
||||
sortOrder,
|
||||
}
|
||||
}
|
||||
},
|
||||
(variables) => variables.id ?? crypto.randomUUID()
|
||||
)
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (variables: CreateWorkflowVariables): Promise<CreateWorkflowResult> => {
|
||||
const { workspaceId, name, description, color, folderId, sortOrder } = variables
|
||||
const { workspaceId, name, description, color, folderId, sortOrder, id } = variables
|
||||
|
||||
logger.info(`Creating new workflow in workspace: ${workspaceId}`)
|
||||
|
||||
@@ -258,6 +273,7 @@ export function useCreateWorkflow() {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id,
|
||||
name: name || generateCreativeWorkflowName(),
|
||||
description: description || 'New workflow',
|
||||
color: color || getNextWorkflowColor(),
|
||||
@@ -346,12 +362,13 @@ export function useDuplicateWorkflowMutation() {
|
||||
targetFolderId
|
||||
),
|
||||
}
|
||||
}
|
||||
},
|
||||
(variables) => variables.newId ?? crypto.randomUUID()
|
||||
)
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (variables: DuplicateWorkflowVariables): Promise<DuplicateWorkflowResult> => {
|
||||
const { workspaceId, sourceId, name, description, color, folderId } = variables
|
||||
const { workspaceId, sourceId, name, description, color, folderId, newId } = variables
|
||||
|
||||
logger.info(`Duplicating workflow ${sourceId} in workspace: ${workspaceId}`)
|
||||
|
||||
@@ -364,6 +381,7 @@ export function useDuplicateWorkflowMutation() {
|
||||
color,
|
||||
workspaceId,
|
||||
folderId: folderId ?? null,
|
||||
newId,
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ interface DuplicateWorkflowOptions {
|
||||
workspaceId?: string
|
||||
folderId?: string | null
|
||||
requestId?: string
|
||||
newWorkflowId?: string
|
||||
}
|
||||
|
||||
interface DuplicateWorkflowResult {
|
||||
@@ -93,10 +94,10 @@ export async function duplicateWorkflow(
|
||||
workspaceId,
|
||||
folderId,
|
||||
requestId = 'unknown',
|
||||
newWorkflowId: clientNewWorkflowId,
|
||||
} = options
|
||||
|
||||
// Generate new workflow ID
|
||||
const newWorkflowId = crypto.randomUUID()
|
||||
const newWorkflowId = clientNewWorkflowId || crypto.randomUUID()
|
||||
const now = new Date()
|
||||
|
||||
// Duplicate workflow and all related data in a transaction
|
||||
|
||||
Reference in New Issue
Block a user