This commit is contained in:
Vikhyath Mondreti
2026-01-14 10:33:19 -08:00
parent 7dc4919220
commit f0d22246a7
13 changed files with 190 additions and 77 deletions

View File

@@ -58,7 +58,7 @@ export async function POST(request: NextRequest) {
}
const body = await request.json()
const { name, workspaceId, parentId, color } = body
const { name, workspaceId, parentId, color, sortOrder: providedSortOrder } = body
if (!name || !workspaceId) {
return NextResponse.json({ error: 'Name and workspace ID are required' }, { status: 400 })
@@ -81,25 +81,26 @@ export async function POST(request: NextRequest) {
// Generate a new ID
const id = crypto.randomUUID()
// Use transaction to ensure sortOrder consistency
const newFolder = await db.transaction(async (tx) => {
// Get the next sort order for the parent (or root level)
// Consider all folders in the workspace, not just those created by current user
const existingFolders = await tx
.select({ sortOrder: workflowFolder.sortOrder })
.from(workflowFolder)
.where(
and(
eq(workflowFolder.workspaceId, workspaceId),
parentId ? eq(workflowFolder.parentId, parentId) : isNull(workflowFolder.parentId)
let sortOrder: number
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)
.orderBy(desc(workflowFolder.sortOrder))
.limit(1)
const nextSortOrder = existingFolders.length > 0 ? existingFolders[0].sortOrder + 1 : 0
sortOrder = existingFolders.length > 0 ? existingFolders[0].sortOrder + 1 : 0
}
// Insert the new folder within the same transaction
const [folder] = await tx
.insert(workflowFolder)
.values({
@@ -109,7 +110,7 @@ export async function POST(request: NextRequest) {
workspaceId,
parentId: parentId || null,
color: color || '#6B7280',
sortOrder: nextSortOrder,
sortOrder,
})
.returning()

View File

@@ -17,6 +17,7 @@ const CreateWorkflowSchema = z.object({
color: z.string().optional().default('#3972F6'),
workspaceId: z.string().optional(),
folderId: z.string().nullable().optional(),
sortOrder: z.number().int().optional(),
})
// GET /api/workflows - Get workflows for user (optionally filtered by workspaceId)
@@ -93,7 +94,14 @@ export async function POST(req: NextRequest) {
try {
const body = await req.json()
const { name, description, color, workspaceId, folderId } = CreateWorkflowSchema.parse(body)
const {
name,
description,
color,
workspaceId,
folderId,
sortOrder: providedSortOrder,
} = CreateWorkflowSchema.parse(body)
if (workspaceId) {
const workspacePermission = await getUserEntityPermissions(
@@ -131,16 +139,21 @@ 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
let sortOrder: number
if (providedSortOrder !== undefined) {
sortOrder = providedSortOrder
} else {
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)
)
sortOrder = (maxResult?.maxOrder ?? -1) + 1
}
await db.insert(workflow).values({
id: workflowId,

View File

@@ -113,36 +113,7 @@ export function WorkflowItem({
[workflow.id, updateWorkflow]
)
const onDragStart = useCallback(
(e: React.DragEvent) => {
if (isEditing) {
e.preventDefault()
return
}
const workflowIds =
isSelected && selectedWorkflows.size > 1 ? Array.from(selectedWorkflows) : [workflow.id]
e.dataTransfer.setData('workflow-ids', JSON.stringify(workflowIds))
e.dataTransfer.effectAllowed = 'move'
onDragStartProp?.()
},
[isSelected, selectedWorkflows, workflow.id, onDragStartProp]
)
const {
isDragging,
shouldPreventClickRef,
handleDragStart,
handleDragEnd: handleDragEndBase,
} = useItemDrag({
onDragStart,
})
const handleDragEnd = useCallback(() => {
handleDragEndBase()
onDragEndProp?.()
}, [handleDragEndBase, onDragEndProp])
const isEditingRef = useRef(false)
const {
isOpen: isContextMenuOpen,
@@ -247,6 +218,43 @@ export function WorkflowItem({
itemId: workflow.id,
})
isEditingRef.current = isEditing
const onDragStart = useCallback(
(e: React.DragEvent) => {
if (isEditingRef.current) {
e.preventDefault()
return
}
const currentSelection = useFolderStore.getState().selectedWorkflows
const isCurrentlySelected = currentSelection.has(workflow.id)
const workflowIds =
isCurrentlySelected && currentSelection.size > 1
? Array.from(currentSelection)
: [workflow.id]
e.dataTransfer.setData('workflow-ids', JSON.stringify(workflowIds))
e.dataTransfer.effectAllowed = 'move'
onDragStartProp?.()
},
[workflow.id, onDragStartProp]
)
const {
isDragging,
shouldPreventClickRef,
handleDragStart,
handleDragEnd: handleDragEndBase,
} = useItemDrag({
onDragStart,
})
const handleDragEnd = useCallback(() => {
handleDragEndBase()
onDragEndProp?.()
}, [handleDragEndBase, onDragEndProp])
/**
* Handle double-click on workflow name to enter rename mode
*/

View File

@@ -241,7 +241,7 @@ export function WorkflowList({
</div>
<DropIndicatorLine show={showAfter} level={level} />
{isExpanded && (
{isExpanded && (hasChildren || isDragging) && (
<div className='relative'>
<div
className='pointer-events-none absolute top-0 bottom-0 w-px bg-[var(--border)]'
@@ -253,7 +253,7 @@ export function WorkflowList({
? renderFolderSection(item.data as FolderTreeNode, level + 1, folder.id)
: renderWorkflowItem(item.data as WorkflowMetadata, level + 1, folder.id)
)}
{!hasChildren && (
{!hasChildren && isDragging && (
<div className='h-[24px]' {...createEmptyFolderDropZone(folder.id)} />
)}
</div>

View File

@@ -158,7 +158,12 @@ export function useDragDrop() {
const remaining = siblingItems.filter(
(item) => !(item.type === 'workflow' && movingSet.has(item.id))
)
const moving = workflowIds.map((id) => ({ type: 'workflow' as const, id, sortOrder: 0 }))
const moving = workflowIds
.map((id) => {
const w = currentWorkflows[id]
return { type: 'workflow' as const, id, sortOrder: w?.sortOrder ?? 0 }
})
.sort((a, b) => a.sortOrder - b.sortOrder)
let insertAt: number
if (indicator.position === 'inside') {

View File

@@ -165,6 +165,7 @@ export function useExportFolder({ workspaceId, folderId, onSuccess }: UseExportF
name: workflow.name,
description: workflow.description,
color: workflow.color,
sortOrder: workflow.sortOrder,
exportedAt: new Date().toISOString(),
},
variables: workflowVariables,

View File

@@ -113,6 +113,7 @@ export function useExportWorkflow({ workspaceId, onSuccess }: UseExportWorkflowP
name: workflow.name,
description: workflow.description,
color: workflow.color,
sortOrder: workflow.sortOrder,
exportedAt: new Date().toISOString(),
},
variables: workflowVariables,

View File

@@ -86,6 +86,7 @@ export function useExportWorkspace({ onSuccess }: UseExportWorkspaceProps = {})
description: workflow.description,
color: workflow.color,
folderId: workflow.folderId,
sortOrder: workflow.sortOrder,
},
state: workflowData.state,
variables: workflowVariables,
@@ -100,6 +101,7 @@ export function useExportWorkspace({ onSuccess }: UseExportWorkspaceProps = {})
id: folder.id,
name: folder.name,
parentId: folder.parentId,
sortOrder: folder.sortOrder,
})
)

View File

@@ -40,7 +40,7 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
* Import a single workflow
*/
const importSingleWorkflow = useCallback(
async (content: string, filename: string, folderId?: string) => {
async (content: string, filename: string, folderId?: string, sortOrder?: number) => {
const { data: workflowData, errors: parseErrors } = parseWorkflowJson(content)
if (!workflowData || parseErrors.length > 0) {
@@ -60,6 +60,7 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
description: workflowData.metadata?.description || 'Imported from JSON',
workspaceId,
folderId: folderId || undefined,
sortOrder,
})
const newWorkflowId = result.id
@@ -140,6 +141,36 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
})
const folderMap = new Map<string, string>()
const exportedFoldersByPath = new Map<string, { sortOrder?: number }>()
if (metadata?.folders) {
type ExportedFolder = {
id: string
name: string
parentId: string | null
sortOrder?: number
}
const foldersById = new Map<string, ExportedFolder>(
metadata.folders.map((f) => [f.id, f])
)
const sanitizeName = (name: string) => name.replace(/[^a-z0-9-_]/gi, '-')
const buildPath = (folderId: string): string => {
const pathParts: string[] = []
let currentId: string | null = folderId
while (currentId && foldersById.has(currentId)) {
const folder: ExportedFolder = foldersById.get(currentId)!
pathParts.unshift(sanitizeName(folder.name))
currentId = folder.parentId
}
return pathParts.join('/')
}
for (const f of metadata.folders) {
const path = buildPath(f.id)
exportedFoldersByPath.set(path, { sortOrder: f.sortOrder })
}
}
for (const workflow of extractedWorkflows) {
try {
let targetFolderId = importFolder.id
@@ -152,12 +183,15 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
for (let i = 0; i < workflow.folderPath.length; i++) {
const pathSegment = workflow.folderPath.slice(0, i + 1).join('/')
const folderNameForSegment = workflow.folderPath[i]
if (!folderMap.has(pathSegment)) {
const exportedFolder = exportedFoldersByPath.get(pathSegment)
const subFolder = await createFolderMutation.mutateAsync({
name: workflow.folderPath[i],
name: folderNameForSegment,
workspaceId,
parentId,
sortOrder: exportedFolder?.sortOrder,
})
folderMap.set(pathSegment, subFolder.id)
parentId = subFolder.id
@@ -173,7 +207,8 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
const workflowId = await importSingleWorkflow(
workflow.content,
workflow.name,
targetFolderId
targetFolderId,
workflow.sortOrder
)
if (workflowId) importedWorkflowIds.push(workflowId)
} catch (error) {

View File

@@ -68,6 +68,7 @@ interface CreateFolderVariables {
name: string
parentId?: string
color?: string
sortOrder?: number
}
interface UpdateFolderVariables {
@@ -160,18 +161,20 @@ export function useCreateFolder() {
parentId: variables.parentId || null,
color: variables.color || '#808080',
isExpanded: false,
sortOrder: getNextSortOrder(previousFolders, variables.workspaceId, variables.parentId),
sortOrder:
variables.sortOrder ??
getNextSortOrder(previousFolders, variables.workspaceId, variables.parentId),
createdAt: new Date(),
updatedAt: new Date(),
})
)
return useMutation({
mutationFn: async ({ workspaceId, ...payload }: CreateFolderVariables) => {
mutationFn: async ({ workspaceId, sortOrder, ...payload }: CreateFolderVariables) => {
const response = await fetch('/api/folders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...payload, workspaceId }),
body: JSON.stringify({ ...payload, workspaceId, sortOrder }),
})
if (!response.ok) {

View File

@@ -92,6 +92,7 @@ interface CreateWorkflowVariables {
description?: string
color?: string
folderId?: string | null
sortOrder?: number
}
interface CreateWorkflowResult {
@@ -184,12 +185,17 @@ export function useCreateWorkflow() {
queryClient,
'CreateWorkflow',
(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)
let sortOrder: number
if (variables.sortOrder !== undefined) {
sortOrder = variables.sortOrder
} else {
const currentWorkflows = useWorkflowRegistry.getState().workflows
const targetFolderId = variables.folderId || null
const workflowsInFolder = Object.values(currentWorkflows).filter(
(w) => w.folderId === targetFolderId
)
sortOrder = workflowsInFolder.reduce((max, w) => Math.max(max, w.sortOrder ?? 0), -1) + 1
}
return {
id: tempId,
@@ -199,15 +205,15 @@ export function useCreateWorkflow() {
description: variables.description || 'New workflow',
color: variables.color || getNextWorkflowColor(),
workspaceId: variables.workspaceId,
folderId: targetFolderId,
sortOrder: maxSortOrder + 1,
folderId: variables.folderId || null,
sortOrder,
}
}
)
return useMutation({
mutationFn: async (variables: CreateWorkflowVariables): Promise<CreateWorkflowResult> => {
const { workspaceId, name, description, color, folderId } = variables
const { workspaceId, name, description, color, folderId, sortOrder } = variables
logger.info(`Creating new workflow in workspace: ${workspaceId}`)
@@ -220,6 +226,7 @@ export function useCreateWorkflow() {
color: color || getNextWorkflowColor(),
workspaceId,
folderId: folderId || null,
sortOrder,
}),
})

View File

@@ -16,6 +16,7 @@ export interface WorkflowExportData {
description?: string
color?: string
folderId?: string | null
sortOrder?: number
}
state: WorkflowState
variables?: Record<string, Variable>
@@ -25,6 +26,7 @@ export interface FolderExportData {
id: string
name: string
parentId: string | null
sortOrder?: number
}
export interface WorkspaceExportStructure {
@@ -71,7 +73,12 @@ export async function exportWorkspaceToZip(
name: workspaceName,
exportedAt: new Date().toISOString(),
},
folders: folders.map((f) => ({ id: f.id, name: f.name, parentId: f.parentId })),
folders: folders.map((f) => ({
id: f.id,
name: f.name,
parentId: f.parentId,
sortOrder: f.sortOrder,
})),
}
zip.file('_workspace.json', JSON.stringify(metadata, null, 2))
@@ -84,6 +91,7 @@ export async function exportWorkspaceToZip(
name: workflow.workflow.name,
description: workflow.workflow.description,
color: workflow.workflow.color,
sortOrder: workflow.workflow.sortOrder,
exportedAt: new Date().toISOString(),
},
variables: workflow.variables,
@@ -109,11 +117,18 @@ export interface ImportedWorkflow {
content: string
name: string
folderPath: string[]
sortOrder?: number
}
export interface WorkspaceImportMetadata {
workspaceName: string
exportedAt?: string
folders?: Array<{
id: string
name: string
parentId: string | null
sortOrder?: number
}>
}
export async function extractWorkflowsFromZip(
@@ -133,6 +148,7 @@ export async function extractWorkflowsFromZip(
metadata = {
workspaceName: parsed.workspace?.name || 'Imported Workspace',
exportedAt: parsed.workspace?.exportedAt,
folders: parsed.folders,
}
} catch (error) {
logger.error('Failed to parse workspace metadata:', error)
@@ -147,10 +163,19 @@ export async function extractWorkflowsFromZip(
const pathParts = path.split('/').filter((p) => p.length > 0)
const filename = pathParts.pop() || path
let sortOrder: number | undefined
try {
const parsed = JSON.parse(content)
sortOrder = parsed.state?.metadata?.sortOrder ?? parsed.metadata?.sortOrder
} catch {
// ignore parse errors for sortOrder extraction
}
workflows.push({
content,
name: filename,
folderPath: pathParts,
sortOrder,
})
} catch (error) {
logger.error(`Failed to extract ${path}:`, error)
@@ -168,10 +193,20 @@ export async function extractWorkflowsFromFiles(files: File[]): Promise<Imported
try {
const content = await file.text()
let sortOrder: number | undefined
try {
const parsed = JSON.parse(content)
sortOrder = parsed.state?.metadata?.sortOrder ?? parsed.metadata?.sortOrder
} catch {
// ignore parse errors for sortOrder extraction
}
workflows.push({
content,
name: file.name,
folderPath: [],
sortOrder,
})
} catch (error) {
logger.error(`Failed to read ${file.name}:`, error)

View File

@@ -53,6 +53,8 @@ export interface ExportWorkflowState {
metadata?: {
name?: string
description?: string
color?: string
sortOrder?: number
exportedAt?: string
}
variables?: Array<{