Compare commits

...

1 Commits

Author SHA1 Message Date
Vikhyath Mondreti
7dc4919220 feat(reorder): allow workflow/folder reordering 2026-01-13 12:18:14 -08:00
15 changed files with 838 additions and 375 deletions

View File

@@ -14,6 +14,7 @@ const updateFolderSchema = z.object({
color: z.string().optional(),
isExpanded: z.boolean().optional(),
parentId: z.string().nullable().optional(),
sortOrder: z.number().int().min(0).optional(),
})
// PUT - Update a folder
@@ -38,7 +39,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: `Validation failed: ${errorMessages}` }, { status: 400 })
}
const { name, color, isExpanded, parentId } = validationResult.data
const { name, color, isExpanded, parentId, sortOrder } = validationResult.data
// Verify the folder exists
const existingFolder = await db
@@ -81,12 +82,12 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
}
}
// Update the folder
const updates: any = { updatedAt: new Date() }
const updates: Record<string, unknown> = { updatedAt: new Date() }
if (name !== undefined) updates.name = name.trim()
if (color !== undefined) updates.color = color
if (isExpanded !== undefined) updates.isExpanded = isExpanded
if (parentId !== undefined) updates.parentId = parentId || null
if (sortOrder !== undefined) updates.sortOrder = sortOrder
const [updatedFolder] = await db
.update(workflowFolder)

View File

@@ -0,0 +1,91 @@
import { db } from '@sim/db'
import { workflowFolder } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq, inArray } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('FolderReorderAPI')
const ReorderSchema = z.object({
workspaceId: z.string(),
updates: z.array(
z.object({
id: z.string(),
sortOrder: z.number().int().min(0),
parentId: z.string().nullable().optional(),
})
),
})
export async function PUT(req: NextRequest) {
const requestId = generateRequestId()
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized folder reorder attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const body = await req.json()
const { workspaceId, updates } = ReorderSchema.parse(body)
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
if (!permission || permission === 'read') {
logger.warn(
`[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}`
)
return NextResponse.json({ error: 'Write access required' }, { status: 403 })
}
const folderIds = updates.map((u) => u.id)
const existingFolders = await db
.select({ id: workflowFolder.id, workspaceId: workflowFolder.workspaceId })
.from(workflowFolder)
.where(inArray(workflowFolder.id, folderIds))
const validIds = new Set(
existingFolders.filter((f) => f.workspaceId === workspaceId).map((f) => f.id)
)
const validUpdates = updates.filter((u) => validIds.has(u.id))
if (validUpdates.length === 0) {
return NextResponse.json({ error: 'No valid folders to update' }, { status: 400 })
}
await db.transaction(async (tx) => {
for (const update of validUpdates) {
const updateData: Record<string, unknown> = {
sortOrder: update.sortOrder,
updatedAt: new Date(),
}
if (update.parentId !== undefined) {
updateData.parentId = update.parentId
}
await tx.update(workflowFolder).set(updateData).where(eq(workflowFolder.id, update.id))
}
})
logger.info(
`[${requestId}] Reordered ${validUpdates.length} folders in workspace ${workspaceId}`
)
return NextResponse.json({ success: true, updated: validUpdates.length })
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid folder reorder data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
logger.error(`[${requestId}] Error reordering folders`, error)
return NextResponse.json({ error: 'Failed to reorder folders' }, { status: 500 })
}
}

View File

@@ -20,6 +20,7 @@ const UpdateWorkflowSchema = z.object({
description: z.string().optional(),
color: z.string().optional(),
folderId: z.string().nullable().optional(),
sortOrder: z.number().int().min(0).optional(),
})
/**
@@ -438,12 +439,12 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
// Build update object
const updateData: any = { updatedAt: new Date() }
const updateData: Record<string, unknown> = { updatedAt: new Date() }
if (updates.name !== undefined) updateData.name = updates.name
if (updates.description !== undefined) updateData.description = updates.description
if (updates.color !== undefined) updateData.color = updates.color
if (updates.folderId !== undefined) updateData.folderId = updates.folderId
if (updates.sortOrder !== undefined) updateData.sortOrder = updates.sortOrder
// Update the workflow
const [updatedWorkflow] = await db

View File

@@ -0,0 +1,91 @@
import { db } from '@sim/db'
import { workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq, inArray } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WorkflowReorderAPI')
const ReorderSchema = z.object({
workspaceId: z.string(),
updates: z.array(
z.object({
id: z.string(),
sortOrder: z.number().int().min(0),
folderId: z.string().nullable().optional(),
})
),
})
export async function PUT(req: NextRequest) {
const requestId = generateRequestId()
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized reorder attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const body = await req.json()
const { workspaceId, updates } = ReorderSchema.parse(body)
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
if (!permission || permission === 'read') {
logger.warn(
`[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}`
)
return NextResponse.json({ error: 'Write access required' }, { status: 403 })
}
const workflowIds = updates.map((u) => u.id)
const existingWorkflows = await db
.select({ id: workflow.id, workspaceId: workflow.workspaceId })
.from(workflow)
.where(inArray(workflow.id, workflowIds))
const validIds = new Set(
existingWorkflows.filter((w) => w.workspaceId === workspaceId).map((w) => w.id)
)
const validUpdates = updates.filter((u) => validIds.has(u.id))
if (validUpdates.length === 0) {
return NextResponse.json({ error: 'No valid workflows to update' }, { status: 400 })
}
await db.transaction(async (tx) => {
for (const update of validUpdates) {
const updateData: Record<string, unknown> = {
sortOrder: update.sortOrder,
updatedAt: new Date(),
}
if (update.folderId !== undefined) {
updateData.folderId = update.folderId
}
await tx.update(workflow).set(updateData).where(eq(workflow.id, update.id))
}
})
logger.info(
`[${requestId}] Reordered ${validUpdates.length} workflows in workspace ${workspaceId}`
)
return NextResponse.json({ success: true, updated: validUpdates.length })
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid reorder data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
logger.error(`[${requestId}] Error reordering workflows`, error)
return NextResponse.json({ error: 'Failed to reorder workflows' }, { status: 500 })
}
}

View File

@@ -1,7 +1,7 @@
import { db } from '@sim/db'
import { workflow, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { and, eq, isNull, max } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
@@ -131,11 +131,23 @@ export async function POST(req: NextRequest) {
// Silently fail
})
const folderCondition = folderId ? eq(workflow.folderId, folderId) : isNull(workflow.folderId)
const [maxResult] = await db
.select({ maxOrder: max(workflow.sortOrder) })
.from(workflow)
.where(
workspaceId
? and(eq(workflow.workspaceId, workspaceId), folderCondition)
: and(eq(workflow.userId, session.user.id), folderCondition)
)
const sortOrder = (maxResult?.maxOrder ?? -1) + 1
await db.insert(workflow).values({
id: workflowId,
userId: session.user.id,
workspaceId: workspaceId || null,
folderId: folderId || null,
sortOrder,
name,
description,
color,
@@ -156,6 +168,7 @@ export async function POST(req: NextRequest) {
color,
workspaceId,
folderId,
sortOrder,
createdAt: now,
updatedAt: now,
})

View File

@@ -36,6 +36,8 @@ interface FolderItemProps {
onDragEnter?: (e: React.DragEvent<HTMLElement>) => void
onDragLeave?: (e: React.DragEvent<HTMLElement>) => void
}
onDragStart?: () => void
onDragEnd?: () => void
}
/**
@@ -46,7 +48,13 @@ interface FolderItemProps {
* @param props - Component props
* @returns Folder item with drag and expand support
*/
export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
export function FolderItem({
folder,
level,
hoverHandlers,
onDragStart: onDragStartProp,
onDragEnd: onDragEndProp,
}: FolderItemProps) {
const params = useParams()
const router = useRouter()
const workspaceId = params.workspaceId as string
@@ -136,11 +144,6 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
}
}, [createFolderMutation, workspaceId, folder.id, expandFolder])
/**
* Drag start handler - sets folder data for drag operation
*
* @param e - React drag event
*/
const onDragStart = useCallback(
(e: React.DragEvent) => {
if (isEditing) {
@@ -150,14 +153,25 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
e.dataTransfer.setData('folder-id', folder.id)
e.dataTransfer.effectAllowed = 'move'
onDragStartProp?.()
},
[folder.id]
[folder.id, onDragStartProp]
)
const { isDragging, shouldPreventClickRef, handleDragStart, handleDragEnd } = useItemDrag({
const {
isDragging,
shouldPreventClickRef,
handleDragStart,
handleDragEnd: handleDragEndBase,
} = useItemDrag({
onDragStart,
})
const handleDragEnd = useCallback(() => {
handleDragEndBase()
onDragEndProp?.()
}, [handleDragEndBase, onDragEndProp])
const {
isOpen: isContextMenuOpen,
position,

View File

@@ -29,6 +29,8 @@ interface WorkflowItemProps {
active: boolean
level: number
onWorkflowClick: (workflowId: string, shiftKey: boolean, metaKey: boolean) => void
onDragStart?: () => void
onDragEnd?: () => void
}
/**
@@ -38,7 +40,14 @@ interface WorkflowItemProps {
* @param props - Component props
* @returns Workflow item with drag and selection support
*/
export function WorkflowItem({ workflow, active, level, onWorkflowClick }: WorkflowItemProps) {
export function WorkflowItem({
workflow,
active,
level,
onWorkflowClick,
onDragStart: onDragStartProp,
onDragEnd: onDragEndProp,
}: WorkflowItemProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const { selectedWorkflows } = useFolderStore()
@@ -104,11 +113,6 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
[workflow.id, updateWorkflow]
)
/**
* Drag start handler - handles workflow dragging with multi-selection support
*
* @param e - React drag event
*/
const onDragStart = useCallback(
(e: React.DragEvent) => {
if (isEditing) {
@@ -121,14 +125,25 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
e.dataTransfer.setData('workflow-ids', JSON.stringify(workflowIds))
e.dataTransfer.effectAllowed = 'move'
onDragStartProp?.()
},
[isSelected, selectedWorkflows, workflow.id]
[isSelected, selectedWorkflows, workflow.id, onDragStartProp]
)
const { isDragging, shouldPreventClickRef, handleDragStart, handleDragEnd } = useItemDrag({
const {
isDragging,
shouldPreventClickRef,
handleDragStart,
handleDragEnd: handleDragEndBase,
} = useItemDrag({
onDragStart,
})
const handleDragEnd = useCallback(() => {
handleDragEndBase()
onDragEndProp?.()
}, [handleDragEndBase, onDragEndProp])
const {
isOpen: isContextMenuOpen,
position,

View File

@@ -14,9 +14,6 @@ import { useFolderStore } from '@/stores/folders/store'
import type { FolderTreeNode } from '@/stores/folders/types'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
/**
* Constants for tree layout and styling
*/
const TREE_SPACING = {
INDENT_PER_LEVEL: 20,
} as const
@@ -29,12 +26,18 @@ interface WorkflowListProps {
scrollContainerRef: React.RefObject<HTMLDivElement | null>
}
/**
* WorkflowList component displays workflows organized by folders with drag-and-drop support.
*
* @param props - Component props
* @returns Workflow list with folders and drag-drop support
*/
function DropIndicatorLine({ show, level = 0 }: { show: boolean; level?: number }) {
if (!show) return null
return (
<div
className='pointer-events-none absolute left-0 right-0 z-20 flex items-center'
style={{ paddingLeft: `${level * TREE_SPACING.INDENT_PER_LEVEL}px` }}
>
<div className='h-[2px] flex-1 rounded-full bg-[#0096FF]' />
</div>
)
}
export function WorkflowList({
regularWorkflows,
isLoading = false,
@@ -48,20 +51,20 @@ export function WorkflowList({
const workflowId = params.workflowId as string
const { isLoading: foldersLoading } = useFolders(workspaceId)
const { getFolderTree, expandedFolders, getFolderPath, setExpanded } = useFolderStore()
const {
dropTargetId,
dropIndicator,
isDragging,
setScrollContainer,
createWorkflowDragHandlers,
createFolderDragHandlers,
createItemDragHandlers,
createRootDragHandlers,
createFolderHeaderHoverHandlers,
createEmptyFolderDropZone,
createRootDropZone,
handleDragStart,
handleDragEnd,
} = useDragDrop()
// Set scroll container when ref changes
useEffect(() => {
if (scrollContainerRef.current) {
setScrollContainer(scrollContainerRef.current)
@@ -76,23 +79,22 @@ export function WorkflowList({
return activeWorkflow?.folderId || null
}, [workflowId, regularWorkflows, isLoading, foldersLoading])
const workflowsByFolder = useMemo(
() =>
regularWorkflows.reduce(
(acc, workflow) => {
const folderId = workflow.folderId || 'root'
if (!acc[folderId]) acc[folderId] = []
acc[folderId].push(workflow)
return acc
},
{} as Record<string, WorkflowMetadata[]>
),
[regularWorkflows]
)
const workflowsByFolder = useMemo(() => {
const grouped = regularWorkflows.reduce(
(acc, workflow) => {
const folderId = workflow.folderId || 'root'
if (!acc[folderId]) acc[folderId] = []
acc[folderId].push(workflow)
return acc
},
{} as Record<string, WorkflowMetadata[]>
)
for (const folderId of Object.keys(grouped)) {
grouped[folderId].sort((a, b) => a.sortOrder - b.sortOrder)
}
return grouped
}, [regularWorkflows])
/**
* Build a flat list of all workflow IDs in display order for range selection
*/
const orderedWorkflowIds = useMemo(() => {
const ids: string[] = []
@@ -106,12 +108,10 @@ export function WorkflowList({
}
}
// Collect from folders first
for (const folder of folderTree) {
collectWorkflowIds(folder)
}
// Then collect root workflows
const rootWorkflows = workflowsByFolder.root || []
for (const workflow of rootWorkflows) {
ids.push(workflow.id)
@@ -120,30 +120,24 @@ export function WorkflowList({
return ids
}, [folderTree, workflowsByFolder])
// Workflow selection hook - uses active workflow ID as anchor for range selection
const { handleWorkflowClick } = useWorkflowSelection({
workflowIds: orderedWorkflowIds,
activeWorkflowId: workflowId,
})
const isWorkflowActive = useCallback(
(workflowId: string) => pathname === `/workspace/${workspaceId}/w/${workflowId}`,
(wfId: string) => pathname === `/workspace/${workspaceId}/w/${wfId}`,
[pathname, workspaceId]
)
/**
* Auto-expand folders and select active workflow.
*/
useEffect(() => {
if (!workflowId || isLoading || foldersLoading) return
// Expand folder path to reveal workflow
if (activeWorkflowFolderId) {
const folderPath = getFolderPath(activeWorkflowFolderId)
folderPath.forEach((folder) => setExpanded(folder.id, true))
}
// Select workflow if not already selected
const { selectedWorkflows, selectOnly } = useFolderStore.getState()
if (!selectedWorkflows.has(workflowId)) {
selectOnly(workflowId)
@@ -151,23 +145,40 @@ export function WorkflowList({
}, [workflowId, activeWorkflowFolderId, isLoading, foldersLoading, getFolderPath, setExpanded])
const renderWorkflowItem = useCallback(
(workflow: WorkflowMetadata, level: number, parentFolderId: string | null = null) => (
<div key={workflow.id} className='relative' {...createItemDragHandlers(parentFolderId)}>
<div
style={{
paddingLeft: `${level * TREE_SPACING.INDENT_PER_LEVEL}px`,
}}
>
<WorkflowItem
workflow={workflow}
active={isWorkflowActive(workflow.id)}
level={level}
onWorkflowClick={handleWorkflowClick}
/>
(workflow: WorkflowMetadata, level: number, folderId: string | null = null) => {
const showBefore =
dropIndicator?.targetId === workflow.id && dropIndicator?.position === 'before'
const showAfter =
dropIndicator?.targetId === workflow.id && dropIndicator?.position === 'after'
return (
<div key={workflow.id} className='relative'>
<DropIndicatorLine show={showBefore} level={level} />
<div
style={{ paddingLeft: `${level * TREE_SPACING.INDENT_PER_LEVEL}px` }}
{...createWorkflowDragHandlers(workflow.id, folderId)}
>
<WorkflowItem
workflow={workflow}
active={isWorkflowActive(workflow.id)}
level={level}
onWorkflowClick={handleWorkflowClick}
onDragStart={() => handleDragStart('workflow')}
onDragEnd={handleDragEnd}
/>
</div>
<DropIndicatorLine show={showAfter} level={level} />
</div>
</div>
),
[isWorkflowActive, createItemDragHandlers, handleWorkflowClick]
)
},
[
dropIndicator,
isWorkflowActive,
createWorkflowDragHandlers,
handleWorkflowClick,
handleDragStart,
handleDragEnd,
]
)
const renderFolderSection = useCallback(
@@ -179,45 +190,72 @@ export function WorkflowList({
const workflowsInFolder = workflowsByFolder[folder.id] || []
const isExpanded = expandedFolders.has(folder.id)
const hasChildren = workflowsInFolder.length > 0 || folder.children.length > 0
const isDropTarget = dropTargetId === folder.id
const showBefore =
dropIndicator?.targetId === folder.id && dropIndicator?.position === 'before'
const showAfter = dropIndicator?.targetId === folder.id && dropIndicator?.position === 'after'
const showInside =
dropIndicator?.targetId === folder.id && dropIndicator?.position === 'inside'
const childItems: Array<{
type: 'folder' | 'workflow'
id: string
sortOrder: number
data: FolderTreeNode | WorkflowMetadata
}> = []
for (const childFolder of folder.children) {
childItems.push({
type: 'folder',
id: childFolder.id,
sortOrder: childFolder.sortOrder,
data: childFolder,
})
}
for (const workflow of workflowsInFolder) {
childItems.push({
type: 'workflow',
id: workflow.id,
sortOrder: workflow.sortOrder,
data: workflow,
})
}
childItems.sort((a, b) => a.sortOrder - b.sortOrder)
return (
<div key={folder.id} className='relative' {...createFolderDragHandlers(folder.id)}>
{/* Drop target highlight overlay - always rendered for stable DOM */}
<div key={folder.id} className='relative'>
<DropIndicatorLine show={showBefore} level={level} />
<div
className={clsx(
'pointer-events-none absolute inset-0 z-10 rounded-[4px] transition-opacity duration-75',
isDropTarget && isDragging ? 'bg-gray-400/20 opacity-100' : 'opacity-0'
'rounded-[4px] transition-colors duration-75',
showInside && isDragging && 'bg-[#0096FF]/10'
)}
/>
<div
style={{ paddingLeft: `${level * TREE_SPACING.INDENT_PER_LEVEL}px` }}
{...createItemDragHandlers(folder.id)}
{...createFolderDragHandlers(folder.id, parentFolderId)}
>
<FolderItem
folder={folder}
level={level}
hoverHandlers={createFolderHeaderHoverHandlers(folder.id)}
onDragStart={() => handleDragStart('folder')}
onDragEnd={handleDragEnd}
/>
</div>
<DropIndicatorLine show={showAfter} level={level} />
{isExpanded && hasChildren && (
<div className='relative' {...createItemDragHandlers(folder.id)}>
{/* Vertical line - positioned to align under folder chevron */}
{isExpanded && (
<div className='relative'>
<div
className='pointer-events-none absolute top-0 bottom-0 w-px bg-[var(--border)]'
style={{ left: `${level * TREE_SPACING.INDENT_PER_LEVEL + 12}px` }}
/>
<div className='mt-[2px] space-y-[2px] pl-[2px]'>
{workflowsInFolder.map((workflow: WorkflowMetadata) =>
renderWorkflowItem(workflow, level + 1, folder.id)
{childItems.map((item) =>
item.type === 'folder'
? renderFolderSection(item.data as FolderTreeNode, level + 1, folder.id)
: renderWorkflowItem(item.data as WorkflowMetadata, level + 1, folder.id)
)}
{!hasChildren && (
<div className='h-[24px]' {...createEmptyFolderDropZone(folder.id)} />
)}
{folder.children.map((childFolder) => (
<div key={childFolder.id} className='relative'>
{renderFolderSection(childFolder, level + 1, folder.id)}
</div>
))}
</div>
</div>
)}
@@ -227,29 +265,46 @@ export function WorkflowList({
[
workflowsByFolder,
expandedFolders,
dropTargetId,
dropIndicator,
isDragging,
createFolderDragHandlers,
createItemDragHandlers,
createFolderHeaderHoverHandlers,
createEmptyFolderDropZone,
handleDragStart,
handleDragEnd,
renderWorkflowItem,
]
)
const handleRootDragEvents = createRootDragHandlers()
const rootDropZoneHandlers = createRootDropZone()
const rootWorkflows = workflowsByFolder.root || []
const isRootDropTarget = dropTargetId === 'root'
const hasRootWorkflows = rootWorkflows.length > 0
const hasFolders = folderTree.length > 0
/**
* Handle click on empty space to revert to active workflow selection
*/
const rootItems = useMemo(() => {
const items: Array<{
type: 'folder' | 'workflow'
id: string
sortOrder: number
data: FolderTreeNode | WorkflowMetadata
}> = []
for (const folder of folderTree) {
items.push({ type: 'folder', id: folder.id, sortOrder: folder.sortOrder, data: folder })
}
for (const workflow of rootWorkflows) {
items.push({
type: 'workflow',
id: workflow.id,
sortOrder: workflow.sortOrder,
data: workflow,
})
}
return items.sort((a, b) => a.sortOrder - b.sortOrder)
}, [folderTree, rootWorkflows])
const hasRootItems = rootItems.length > 0
const showRootInside = dropIndicator?.targetId === 'root' && dropIndicator?.position === 'inside'
const handleContainerClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
// Only handle clicks directly on the container (empty space)
if (e.target !== e.currentTarget) return
const { selectOnly, clearSelection } = useFolderStore.getState()
workflowId ? selectOnly(workflowId) : clearSelection()
},
@@ -258,36 +313,20 @@ export function WorkflowList({
return (
<div className='flex min-h-full flex-col pb-[8px]' onClick={handleContainerClick}>
{/* Folders Section */}
{hasFolders && (
<div className='mb-[2px] space-y-[2px]'>
{folderTree.map((folder) => renderFolderSection(folder, 0))}
</div>
)}
{/* Root Workflows Section - Expands to fill remaining space */}
<div
className={clsx('relative flex-1', !hasRootWorkflows && 'min-h-[26px]')}
{...handleRootDragEvents}
className={clsx(
'relative flex-1 rounded-[4px] transition-colors duration-75',
!hasRootItems && 'min-h-[26px]',
showRootInside && isDragging && 'bg-[#0096FF]/10'
)}
{...rootDropZoneHandlers}
>
{/* Root drop target highlight overlay - always rendered for stable DOM */}
<div
className={clsx(
'pointer-events-none absolute inset-0 z-10 rounded-[4px] transition-opacity duration-75',
isRootDropTarget && isDragging ? 'bg-gray-400/20 opacity-100' : 'opacity-0'
)}
/>
<div className='space-y-[2px]'>
{rootWorkflows.map((workflow: WorkflowMetadata) => (
<WorkflowItem
key={workflow.id}
workflow={workflow}
active={isWorkflowActive(workflow.id)}
level={0}
onWorkflowClick={handleWorkflowClick}
/>
))}
{rootItems.map((item) =>
item.type === 'folder'
? renderFolderSection(item.data as FolderTreeNode, 0, null)
: renderWorkflowItem(item.data as WorkflowMetadata, 0, null)
)}
</div>
</div>

View File

@@ -1,6 +1,6 @@
export { useAutoScroll } from './use-auto-scroll'
export { useContextMenu } from './use-context-menu'
export { useDragDrop } from './use-drag-drop'
export { type DropIndicator, useDragDrop } from './use-drag-drop'
export { useFolderExpand } from './use-folder-expand'
export { useFolderOperations } from './use-folder-operations'
export { useItemDrag } from './use-item-drag'

View File

@@ -1,47 +1,39 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams } from 'next/navigation'
import { useUpdateFolder } from '@/hooks/queries/folders'
import { useReorderFolders } from '@/hooks/queries/folders'
import { useReorderWorkflows } from '@/hooks/queries/workflows'
import { useFolderStore } from '@/stores/folders/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('WorkflowList:DragDrop')
/**
* Constants for auto-scroll behavior
*/
const SCROLL_THRESHOLD = 60 // Distance from edge to trigger scroll
const SCROLL_SPEED = 8 // Pixels per frame
const SCROLL_THRESHOLD = 60
const SCROLL_SPEED = 8
const HOVER_EXPAND_DELAY = 400
/**
* Constants for folder auto-expand on hover during drag
*/
const HOVER_EXPAND_DELAY = 400 // Milliseconds to wait before expanding folder
export interface DropIndicator {
targetId: string
position: 'before' | 'after' | 'inside'
folderId: string | null
}
/**
* Custom hook for handling drag and drop operations for workflows and folders.
* Includes auto-scrolling, drop target highlighting, and hover-to-expand.
*
* @returns Drag and drop state and event handlers
*/
export function useDragDrop() {
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
const [dropIndicator, setDropIndicator] = useState<DropIndicator | null>(null)
const [isDragging, setIsDragging] = useState(false)
const [hoverFolderId, setHoverFolderId] = useState<string | null>(null)
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
const scrollIntervalRef = useRef<number | null>(null)
const hoverExpandTimerRef = useRef<number | null>(null)
const lastDragYRef = useRef<number>(0)
const draggedTypeRef = useRef<'workflow' | 'folder' | null>(null)
const params = useParams()
const workspaceId = params.workspaceId as string | undefined
const updateFolderMutation = useUpdateFolder()
const reorderWorkflowsMutation = useReorderWorkflows()
const reorderFoldersMutation = useReorderFolders()
const { setExpanded, expandedFolders } = useFolderStore()
const { updateWorkflow } = useWorkflowRegistry()
/**
* Auto-scroll handler - scrolls container when dragging near edges
*/
const handleAutoScroll = useCallback(() => {
if (!scrollContainerRef.current || !isDragging) return
@@ -49,22 +41,17 @@ export function useDragDrop() {
const rect = container.getBoundingClientRect()
const mouseY = lastDragYRef.current
// Only scroll if mouse is within container bounds
if (mouseY < rect.top || mouseY > rect.bottom) return
// Calculate distance from top and bottom edges
const distanceFromTop = mouseY - rect.top
const distanceFromBottom = rect.bottom - mouseY
let scrollDelta = 0
// Scroll up if near top and not at scroll top
if (distanceFromTop < SCROLL_THRESHOLD && container.scrollTop > 0) {
const intensity = Math.max(0, Math.min(1, 1 - distanceFromTop / SCROLL_THRESHOLD))
scrollDelta = -SCROLL_SPEED * intensity
}
// Scroll down if near bottom and not at scroll bottom
else if (distanceFromBottom < SCROLL_THRESHOLD) {
} else if (distanceFromBottom < SCROLL_THRESHOLD) {
const maxScroll = container.scrollHeight - container.clientHeight
if (container.scrollTop < maxScroll) {
const intensity = Math.max(0, Math.min(1, 1 - distanceFromBottom / SCROLL_THRESHOLD))
@@ -77,12 +64,9 @@ export function useDragDrop() {
}
}, [isDragging])
/**
* Start auto-scroll animation loop
*/
useEffect(() => {
if (isDragging) {
scrollIntervalRef.current = window.setInterval(handleAutoScroll, 10) // ~100fps for smoother response
scrollIntervalRef.current = window.setInterval(handleAutoScroll, 10)
} else {
if (scrollIntervalRef.current) {
clearInterval(scrollIntervalRef.current)
@@ -97,30 +81,17 @@ export function useDragDrop() {
}
}, [isDragging, handleAutoScroll])
/**
* Handle hover folder changes - start/clear expand timer
*/
useEffect(() => {
// Clear existing timer when hover folder changes
if (hoverExpandTimerRef.current) {
clearTimeout(hoverExpandTimerRef.current)
hoverExpandTimerRef.current = null
}
// Don't start timer if not dragging or no folder is hovered
if (!isDragging || !hoverFolderId) {
return
}
if (!isDragging || !hoverFolderId) return
if (expandedFolders.has(hoverFolderId)) return
// Don't expand if folder is already expanded
if (expandedFolders.has(hoverFolderId)) {
return
}
// Start timer to expand folder after delay
hoverExpandTimerRef.current = window.setTimeout(() => {
setExpanded(hoverFolderId, true)
logger.info(`Auto-expanded folder ${hoverFolderId} during drag`)
}, HOVER_EXPAND_DELAY)
return () => {
@@ -131,249 +102,333 @@ export function useDragDrop() {
}
}, [hoverFolderId, isDragging, expandedFolders, setExpanded])
/**
* Cleanup hover state when dragging stops
*/
useEffect(() => {
if (!isDragging) {
setHoverFolderId(null)
setDropIndicator(null)
draggedTypeRef.current = null
}
}, [isDragging])
/**
* Moves one or more workflows to a target folder
*
* @param workflowIds - Array of workflow IDs to move
* @param targetFolderId - Target folder ID or null for root
*/
const handleWorkflowDrop = useCallback(
async (workflowIds: string[], targetFolderId: string | null) => {
if (!workflowIds.length) {
logger.warn('No workflows to move')
return
}
try {
await Promise.all(
workflowIds.map((workflowId) => updateWorkflow(workflowId, { folderId: targetFolderId }))
)
logger.info(`Moved ${workflowIds.length} workflow(s)`)
} catch (error) {
logger.error('Failed to move workflows:', error)
}
const calculateDropPosition = useCallback(
(e: React.DragEvent, element: HTMLElement): 'before' | 'after' => {
const rect = element.getBoundingClientRect()
const midY = rect.top + rect.height / 2
return e.clientY < midY ? 'before' : 'after'
},
[updateWorkflow]
[]
)
/**
* Moves a folder to a new parent folder, with validation
*
* @param draggedFolderId - ID of the folder being moved
* @param targetFolderId - Target folder ID or null for root
*/
const handleFolderMove = useCallback(
async (draggedFolderId: string, targetFolderId: string | null) => {
if (!draggedFolderId) {
logger.warn('No folder to move')
return
const handleWorkflowDrop = useCallback(
async (workflowIds: string[], indicator: DropIndicator) => {
if (!workflowIds.length || !workspaceId) return
try {
const destinationFolderId =
indicator.position === 'inside'
? indicator.targetId === 'root'
? null
: indicator.targetId
: indicator.folderId
type SiblingItem = { type: 'folder' | 'workflow'; id: string; sortOrder: number }
const currentFolders = useFolderStore.getState().folders
const currentWorkflows = useWorkflowRegistry.getState().workflows
const siblingFolders = Object.values(currentFolders).filter(
(f) => f.parentId === destinationFolderId
)
const siblingWorkflows = Object.values(currentWorkflows).filter(
(w) => w.folderId === destinationFolderId
)
const siblingItems: SiblingItem[] = [
...siblingFolders.map((f) => ({
type: 'folder' as const,
id: f.id,
sortOrder: f.sortOrder,
})),
...siblingWorkflows.map((w) => ({
type: 'workflow' as const,
id: w.id,
sortOrder: w.sortOrder,
})),
].sort((a, b) => a.sortOrder - b.sortOrder)
const movingSet = new Set(workflowIds)
const remaining = siblingItems.filter(
(item) => !(item.type === 'workflow' && movingSet.has(item.id))
)
const moving = workflowIds.map((id) => ({ type: 'workflow' as const, id, sortOrder: 0 }))
let insertAt: number
if (indicator.position === 'inside') {
insertAt = remaining.length
} else {
const targetIdx = remaining.findIndex((item) => item.id === indicator.targetId)
insertAt = indicator.position === 'before' ? targetIdx : targetIdx + 1
}
const newOrder: SiblingItem[] = [
...remaining.slice(0, insertAt),
...moving,
...remaining.slice(insertAt),
]
const folderUpdates = newOrder
.map((item, i) => ({ ...item, sortOrder: i }))
.filter((item) => item.type === 'folder')
.map((item) => ({
id: item.id,
sortOrder: item.sortOrder,
parentId: destinationFolderId,
}))
const workflowUpdates = newOrder
.map((item, i) => ({ ...item, sortOrder: i }))
.filter((item) => item.type === 'workflow')
.map((item) => ({
id: item.id,
sortOrder: item.sortOrder,
folderId: destinationFolderId,
}))
await Promise.all([
folderUpdates.length > 0 &&
reorderFoldersMutation.mutateAsync({ workspaceId, updates: folderUpdates }),
workflowUpdates.length > 0 &&
reorderWorkflowsMutation.mutateAsync({ workspaceId, updates: workflowUpdates }),
])
} catch (error) {
logger.error('Failed to reorder workflows:', error)
}
},
[workspaceId, reorderFoldersMutation, reorderWorkflowsMutation]
)
const handleFolderDrop = useCallback(
async (draggedFolderId: string, indicator: DropIndicator) => {
if (!draggedFolderId || !workspaceId) return
try {
const folderStore = useFolderStore.getState()
const draggedFolderPath = folderStore.getFolderPath(draggedFolderId)
const currentFolders = folderStore.folders
// Prevent moving folder into its own descendant
if (
targetFolderId &&
draggedFolderPath.some((ancestor) => ancestor.id === targetFolderId)
) {
logger.info('Cannot move folder into its own descendant')
return
}
const targetParentId =
indicator.position === 'inside'
? indicator.targetId === 'root'
? null
: indicator.targetId
: indicator.folderId
// Prevent moving folder into itself
if (draggedFolderId === targetFolderId) {
if (draggedFolderId === targetParentId) {
logger.info('Cannot move folder into itself')
return
}
if (!workspaceId) {
logger.warn('No workspaceId available for folder move')
return
if (targetParentId) {
const targetPath = folderStore.getFolderPath(targetParentId)
if (targetPath.some((f) => f.id === draggedFolderId)) {
logger.info('Cannot move folder into its own descendant')
return
}
}
await updateFolderMutation.mutateAsync({
workspaceId,
id: draggedFolderId,
updates: { parentId: targetFolderId },
})
logger.info(`Moved folder to ${targetFolderId ? `folder ${targetFolderId}` : 'root'}`)
type SiblingItem = { type: 'folder' | 'workflow'; id: string; sortOrder: number }
const currentWorkflows = useWorkflowRegistry.getState().workflows
const siblingFolders = Object.values(currentFolders).filter(
(f) => f.parentId === targetParentId
)
const siblingWorkflows = Object.values(currentWorkflows).filter(
(w) => w.folderId === targetParentId
)
const siblingItems: SiblingItem[] = [
...siblingFolders.map((f) => ({
type: 'folder' as const,
id: f.id,
sortOrder: f.sortOrder,
})),
...siblingWorkflows.map((w) => ({
type: 'workflow' as const,
id: w.id,
sortOrder: w.sortOrder,
})),
].sort((a, b) => a.sortOrder - b.sortOrder)
const remaining = siblingItems.filter(
(item) => !(item.type === 'folder' && item.id === draggedFolderId)
)
let insertAt: number
if (indicator.position === 'inside') {
insertAt = remaining.length
} else {
const targetIdx = remaining.findIndex((item) => item.id === indicator.targetId)
insertAt = indicator.position === 'before' ? targetIdx : targetIdx + 1
}
const newOrder: SiblingItem[] = [
...remaining.slice(0, insertAt),
{ type: 'folder', id: draggedFolderId, sortOrder: 0 },
...remaining.slice(insertAt),
]
const folderUpdates = newOrder
.map((item, i) => ({ ...item, sortOrder: i }))
.filter((item) => item.type === 'folder')
.map((item) => ({ id: item.id, sortOrder: item.sortOrder, parentId: targetParentId }))
const workflowUpdates = newOrder
.map((item, i) => ({ ...item, sortOrder: i }))
.filter((item) => item.type === 'workflow')
.map((item) => ({ id: item.id, sortOrder: item.sortOrder, folderId: targetParentId }))
await Promise.all([
folderUpdates.length > 0 &&
reorderFoldersMutation.mutateAsync({ workspaceId, updates: folderUpdates }),
workflowUpdates.length > 0 &&
reorderWorkflowsMutation.mutateAsync({ workspaceId, updates: workflowUpdates }),
])
} catch (error) {
logger.error('Failed to move folder:', error)
logger.error('Failed to reorder folder:', error)
}
},
[updateFolderMutation, workspaceId]
[workspaceId, reorderFoldersMutation, reorderWorkflowsMutation]
)
/**
* Handles drop events for both workflows and folders
*
* @param e - React drag event
* @param targetFolderId - Target folder ID or null for root
*/
const handleFolderDrop = useCallback(
async (e: React.DragEvent, targetFolderId: string | null) => {
const handleDrop = useCallback(
async (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setDropTargetId(null)
const indicator = dropIndicator
setDropIndicator(null)
setIsDragging(false)
if (!indicator) return
try {
// Check if dropping workflows
const workflowIdsData = e.dataTransfer.getData('workflow-ids')
if (workflowIdsData) {
const workflowIds = JSON.parse(workflowIdsData) as string[]
await handleWorkflowDrop(workflowIds, targetFolderId)
await handleWorkflowDrop(workflowIds, indicator)
return
}
// Check if dropping a folder
const folderIdData = e.dataTransfer.getData('folder-id')
if (folderIdData && targetFolderId !== folderIdData) {
await handleFolderMove(folderIdData, targetFolderId)
if (folderIdData) {
await handleFolderDrop(folderIdData, indicator)
}
} catch (error) {
logger.error('Failed to handle drop:', error)
}
},
[handleWorkflowDrop, handleFolderMove]
[dropIndicator, handleWorkflowDrop, handleFolderDrop]
)
/**
* Creates drag event handlers for a specific folder section
* These handlers are attached to the entire folder section container
*
* @param folderId - Folder ID to create handlers for
* @returns Object containing drag event handlers
*/
const createFolderDragHandlers = useCallback(
(folderId: string) => ({
onDragEnter: (e: React.DragEvent<HTMLElement>) => {
e.preventDefault()
setIsDragging(true)
},
onDragOver: (e: React.DragEvent<HTMLElement>) => {
e.preventDefault()
lastDragYRef.current = e.clientY
setDropTargetId(folderId)
setIsDragging(true)
},
onDragLeave: (e: React.DragEvent<HTMLElement>) => {
e.preventDefault()
const relatedTarget = e.relatedTarget as HTMLElement | null
const currentTarget = e.currentTarget as HTMLElement
// Only clear if we're leaving the folder section completely
if (!relatedTarget || !currentTarget.contains(relatedTarget)) {
setDropTargetId(null)
}
},
onDrop: (e: React.DragEvent<HTMLElement>) => handleFolderDrop(e, folderId),
}),
[handleFolderDrop]
)
/**
* Creates drag event handlers for items (workflows/folders) that belong to a parent folder
* When dragging over an item, highlights the parent folder section
*
* @param parentFolderId - Parent folder ID or null for root
* @returns Object containing drag event handlers
*/
const createItemDragHandlers = useCallback(
(parentFolderId: string | null) => ({
const createWorkflowDragHandlers = useCallback(
(workflowId: string, folderId: string | null) => ({
onDragOver: (e: React.DragEvent<HTMLElement>) => {
e.preventDefault()
e.stopPropagation()
lastDragYRef.current = e.clientY
setDropTargetId(parentFolderId || 'root')
setIsDragging(true)
const position = calculateDropPosition(e, e.currentTarget)
setDropIndicator({ targetId: workflowId, position, folderId })
},
onDrop: handleDrop,
}),
[]
[calculateDropPosition, handleDrop]
)
/**
* Creates drag event handlers for the root drop zone
*
* @returns Object containing drag event handlers for root
*/
const createRootDragHandlers = useCallback(
() => ({
onDragEnter: (e: React.DragEvent<HTMLElement>) => {
e.preventDefault()
setIsDragging(true)
},
const createFolderDragHandlers = useCallback(
(folderId: string, parentFolderId: string | null) => ({
onDragOver: (e: React.DragEvent<HTMLElement>) => {
e.preventDefault()
e.stopPropagation()
lastDragYRef.current = e.clientY
setDropTargetId('root')
setIsDragging(true)
},
onDragLeave: (e: React.DragEvent<HTMLElement>) => {
e.preventDefault()
const relatedTarget = e.relatedTarget as HTMLElement | null
const currentTarget = e.currentTarget as HTMLElement
// Only clear if we're leaving the root completely
if (!relatedTarget || !currentTarget.contains(relatedTarget)) {
setDropTargetId(null)
}
},
onDrop: (e: React.DragEvent<HTMLElement>) => handleFolderDrop(e, null),
}),
[handleFolderDrop]
)
/**
* Creates drag event handlers for folder header (the clickable part)
* These handlers trigger folder expansion on hover during drag
*
* @param folderId - Folder ID to handle hover for
* @returns Object containing drag event handlers for folder header
*/
const createFolderHeaderHoverHandlers = useCallback(
(folderId: string) => ({
onDragEnter: (e: React.DragEvent<HTMLElement>) => {
if (isDragging) {
if (draggedTypeRef.current === 'folder') {
const position = calculateDropPosition(e, e.currentTarget)
setDropIndicator({ targetId: folderId, position, folderId: parentFolderId })
} else {
setDropIndicator({ targetId: folderId, position: 'inside', folderId: parentFolderId })
setHoverFolderId(folderId)
}
},
onDragLeave: (e: React.DragEvent<HTMLElement>) => {
const relatedTarget = e.relatedTarget as HTMLElement | null
const currentTarget = e.currentTarget as HTMLElement
// Only clear if we're leaving the folder header completely
if (!relatedTarget || !currentTarget.contains(relatedTarget)) {
setHoverFolderId(null)
}
},
onDrop: handleDrop,
}),
[isDragging]
[calculateDropPosition, handleDrop]
)
/**
* Set the scroll container ref for auto-scrolling
*
* @param element - Scrollable container element
*/
const createEmptyFolderDropZone = useCallback(
(folderId: string) => ({
onDragOver: (e: React.DragEvent<HTMLElement>) => {
e.preventDefault()
e.stopPropagation()
lastDragYRef.current = e.clientY
setIsDragging(true)
setDropIndicator({ targetId: folderId, position: 'inside', folderId })
},
onDrop: handleDrop,
}),
[handleDrop]
)
const createRootDropZone = useCallback(
() => ({
onDragOver: (e: React.DragEvent<HTMLElement>) => {
e.preventDefault()
lastDragYRef.current = e.clientY
setIsDragging(true)
setDropIndicator({ targetId: 'root', position: 'inside', folderId: null })
},
onDragLeave: (e: React.DragEvent<HTMLElement>) => {
const relatedTarget = e.relatedTarget as HTMLElement | null
const currentTarget = e.currentTarget as HTMLElement
if (!relatedTarget || !currentTarget.contains(relatedTarget)) {
setDropIndicator(null)
}
},
onDrop: handleDrop,
}),
[handleDrop]
)
const handleDragStart = useCallback((type: 'workflow' | 'folder') => {
draggedTypeRef.current = type
setIsDragging(true)
}, [])
const handleDragEnd = useCallback(() => {
setIsDragging(false)
setDropIndicator(null)
draggedTypeRef.current = null
setHoverFolderId(null)
}, [])
const setScrollContainer = useCallback((element: HTMLDivElement | null) => {
scrollContainerRef.current = element
}, [])
return {
dropTargetId,
dropIndicator,
isDragging,
setScrollContainer,
createWorkflowDragHandlers,
createFolderDragHandlers,
createItemDragHandlers,
createRootDragHandlers,
createFolderHeaderHoverHandlers,
createEmptyFolderDropZone,
createRootDropZone,
handleDragStart,
handleDragEnd,
}
}

View File

@@ -285,9 +285,66 @@ export function useDuplicateFolderMutation() {
},
...handlers,
onSettled: (_data, _error, variables) => {
// Invalidate both folders and workflows (duplicated folder may contain workflows)
queryClient.invalidateQueries({ queryKey: folderKeys.list(variables.workspaceId) })
queryClient.invalidateQueries({ queryKey: workflowKeys.list(variables.workspaceId) })
},
})
}
interface ReorderFoldersVariables {
workspaceId: string
updates: Array<{
id: string
sortOrder: number
parentId?: string | null
}>
}
export function useReorderFolders() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (variables: ReorderFoldersVariables): Promise<void> => {
const response = await fetch('/api/folders/reorder', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(variables),
})
if (!response.ok) {
const error = await response.json().catch(() => ({}))
throw new Error(error.error || 'Failed to reorder folders')
}
},
onMutate: async (variables) => {
await queryClient.cancelQueries({ queryKey: folderKeys.list(variables.workspaceId) })
const snapshot = { ...useFolderStore.getState().folders }
useFolderStore.setState((state) => {
const updated = { ...state.folders }
for (const update of variables.updates) {
if (updated[update.id]) {
updated[update.id] = {
...updated[update.id],
sortOrder: update.sortOrder,
parentId:
update.parentId !== undefined ? update.parentId : updated[update.id].parentId,
}
}
}
return { folders: updated }
})
return { snapshot }
},
onError: (_error, _variables, context) => {
if (context?.snapshot) {
useFolderStore.setState({ folders: context.snapshot })
}
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: folderKeys.list(variables.workspaceId) })
},
})
}

View File

@@ -32,6 +32,7 @@ function mapWorkflow(workflow: any): WorkflowMetadata {
color: workflow.color,
workspaceId: workflow.workspaceId,
folderId: workflow.folderId,
sortOrder: workflow.sortOrder ?? 0,
createdAt: new Date(workflow.createdAt),
lastModified: new Date(workflow.updatedAt || workflow.createdAt),
}
@@ -100,6 +101,7 @@ interface CreateWorkflowResult {
color: string
workspaceId: string
folderId?: string | null
sortOrder: number
}
interface DuplicateWorkflowVariables {
@@ -118,6 +120,7 @@ interface DuplicateWorkflowResult {
color: string
workspaceId: string
folderId?: string | null
sortOrder: number
blocksCount: number
edgesCount: number
subflowsCount: number
@@ -161,6 +164,7 @@ function createWorkflowMutationHandlers<TVariables extends { workspaceId: string
color: data.color,
workspaceId: data.workspaceId,
folderId: data.folderId,
sortOrder: 'sortOrder' in data ? data.sortOrder : 0,
},
},
error: null,
@@ -179,16 +183,26 @@ export function useCreateWorkflow() {
const handlers = createWorkflowMutationHandlers<CreateWorkflowVariables>(
queryClient,
'CreateWorkflow',
(variables, tempId) => ({
id: tempId,
name: variables.name || generateCreativeWorkflowName(),
lastModified: new Date(),
createdAt: new Date(),
description: variables.description || 'New workflow',
color: variables.color || getNextWorkflowColor(),
workspaceId: variables.workspaceId,
folderId: variables.folderId || null,
})
(variables, tempId) => {
const currentWorkflows = useWorkflowRegistry.getState().workflows
const targetFolderId = variables.folderId || null
const workflowsInFolder = Object.values(currentWorkflows).filter(
(w) => w.folderId === targetFolderId
)
const maxSortOrder = workflowsInFolder.reduce((max, w) => Math.max(max, w.sortOrder ?? 0), -1)
return {
id: tempId,
name: variables.name || generateCreativeWorkflowName(),
lastModified: new Date(),
createdAt: new Date(),
description: variables.description || 'New workflow',
color: variables.color || getNextWorkflowColor(),
workspaceId: variables.workspaceId,
folderId: targetFolderId,
sortOrder: maxSortOrder + 1,
}
}
)
return useMutation({
@@ -243,13 +257,13 @@ export function useCreateWorkflow() {
color: createdWorkflow.color,
workspaceId,
folderId: createdWorkflow.folderId,
sortOrder: createdWorkflow.sortOrder ?? 0,
}
},
...handlers,
onSuccess: (data, variables, context) => {
handlers.onSuccess(data, variables, context)
// Initialize subblock values for new workflow
const { subBlockValues } = buildDefaultWorkflowArtifacts()
useSubBlockStore.setState((state) => ({
workflowValues: {
@@ -267,16 +281,26 @@ export function useDuplicateWorkflowMutation() {
const handlers = createWorkflowMutationHandlers<DuplicateWorkflowVariables>(
queryClient,
'DuplicateWorkflow',
(variables, tempId) => ({
id: tempId,
name: variables.name,
lastModified: new Date(),
createdAt: new Date(),
description: variables.description,
color: variables.color,
workspaceId: variables.workspaceId,
folderId: variables.folderId || null,
})
(variables, tempId) => {
const currentWorkflows = useWorkflowRegistry.getState().workflows
const targetFolderId = variables.folderId || null
const workflowsInFolder = Object.values(currentWorkflows).filter(
(w) => w.folderId === targetFolderId
)
const maxSortOrder = workflowsInFolder.reduce((max, w) => Math.max(max, w.sortOrder ?? 0), -1)
return {
id: tempId,
name: variables.name,
lastModified: new Date(),
createdAt: new Date(),
description: variables.description,
color: variables.color,
workspaceId: variables.workspaceId,
folderId: targetFolderId,
sortOrder: maxSortOrder + 1,
}
}
)
return useMutation({
@@ -317,6 +341,7 @@ export function useDuplicateWorkflowMutation() {
color: duplicatedWorkflow.color || color,
workspaceId,
folderId: duplicatedWorkflow.folderId ?? folderId,
sortOrder: duplicatedWorkflow.sortOrder ?? 0,
blocksCount: duplicatedWorkflow.blocksCount || 0,
edgesCount: duplicatedWorkflow.edgesCount || 0,
subflowsCount: duplicatedWorkflow.subflowsCount || 0,
@@ -398,3 +423,61 @@ export function useRevertToVersion() {
},
})
}
interface ReorderWorkflowsVariables {
workspaceId: string
updates: Array<{
id: string
sortOrder: number
folderId?: string | null
}>
}
export function useReorderWorkflows() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (variables: ReorderWorkflowsVariables): Promise<void> => {
const response = await fetch('/api/workflows/reorder', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(variables),
})
if (!response.ok) {
const error = await response.json().catch(() => ({}))
throw new Error(error.error || 'Failed to reorder workflows')
}
},
onMutate: async (variables) => {
await queryClient.cancelQueries({ queryKey: workflowKeys.list(variables.workspaceId) })
const snapshot = { ...useWorkflowRegistry.getState().workflows }
useWorkflowRegistry.setState((state) => {
const updated = { ...state.workflows }
for (const update of variables.updates) {
if (updated[update.id]) {
updated[update.id] = {
...updated[update.id],
sortOrder: update.sortOrder,
folderId:
update.folderId !== undefined ? update.folderId : updated[update.id].folderId,
}
}
}
return { workflows: updated }
})
return { snapshot }
},
onError: (_error, _variables, context) => {
if (context?.snapshot) {
useWorkflowRegistry.setState({ workflows: context.snapshot })
}
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: workflowKeys.list(variables.workspaceId) })
},
})
}

View File

@@ -476,7 +476,6 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
// Use the server-generated ID
const id = duplicatedWorkflow.id
// Generate new workflow metadata using the server-generated ID
const newWorkflow: WorkflowMetadata = {
id,
name: `${sourceWorkflow.name} (Copy)`,
@@ -484,8 +483,9 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
createdAt: new Date(),
description: sourceWorkflow.description,
color: getNextWorkflowColor(),
workspaceId, // Include the workspaceId in the new workflow
folderId: sourceWorkflow.folderId, // Include the folderId from source workflow
workspaceId,
folderId: sourceWorkflow.folderId,
sortOrder: duplicatedWorkflow.sortOrder ?? 0,
}
// Get the current workflow state to copy from

View File

@@ -26,6 +26,7 @@ export interface WorkflowMetadata {
color: string
workspaceId?: string
folderId?: string | null
sortOrder: number
}
export type HydrationPhase =

View File

@@ -150,6 +150,7 @@ export const workflow = pgTable(
.references(() => user.id, { onDelete: 'cascade' }),
workspaceId: text('workspace_id').references(() => workspace.id, { onDelete: 'cascade' }),
folderId: text('folder_id').references(() => workflowFolder.id, { onDelete: 'set null' }),
sortOrder: integer('sort_order').notNull().default(0),
name: text('name').notNull(),
description: text('description'),
color: text('color').notNull().default('#3972F6'),
@@ -166,6 +167,7 @@ export const workflow = pgTable(
userIdIdx: index('workflow_user_id_idx').on(table.userId),
workspaceIdIdx: index('workflow_workspace_id_idx').on(table.workspaceId),
userWorkspaceIdx: index('workflow_user_workspace_idx').on(table.userId, table.workspaceId),
folderSortIdx: index('workflow_folder_sort_idx').on(table.folderId, table.sortOrder),
})
)