mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-28 03:00:29 -04:00
progress
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -53,6 +53,8 @@ export interface ExportWorkflowState {
|
||||
metadata?: {
|
||||
name?: string
|
||||
description?: string
|
||||
color?: string
|
||||
sortOrder?: number
|
||||
exportedAt?: string
|
||||
}
|
||||
variables?: Array<{
|
||||
|
||||
Reference in New Issue
Block a user