chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,11 @@
export { useCanDelete } from './use-can-delete'
export { useDeleteFolder } from './use-delete-folder'
export { useDeleteSelection } from './use-delete-selection'
export { useDeleteWorkflow } from './use-delete-workflow'
export { useDuplicateFolder } from './use-duplicate-folder'
export { useDuplicateSelection } from './use-duplicate-selection'
export { useDuplicateWorkflow } from './use-duplicate-workflow'
export { useExportFolder } from './use-export-folder'
export { useExportSelection } from './use-export-selection'
export { useExportWorkflow } from './use-export-workflow'
export { useImportWorkflow } from './use-import-workflow'
@@ -0,0 +1,128 @@
import { useCallback, useMemo } from 'react'
import { useFolderMap } from '@/hooks/queries/folders'
import { useWorkflows } from '@/hooks/queries/workflows'
interface UseCanDeleteProps {
/**
* Current workspace ID
*/
workspaceId: string
}
interface UseCanDeleteReturn {
/**
* Checks if the given workflow IDs can be deleted.
* Returns false if deleting them would leave no workflows in the workspace.
*/
canDeleteWorkflows: (workflowIds: string[]) => boolean
/**
* Checks if the given folder can be deleted.
* Returns false if deleting it would leave no workflows in the workspace.
*/
canDeleteFolder: (folderId: string) => boolean
/**
* Total number of workflows in the workspace.
*/
totalWorkflows: number
}
/**
* Hook for checking if workflows or folders can be deleted.
* Prevents deletion if it would leave the workspace with no workflows.
*
* Uses pre-computed lookup maps for O(1) access instead of repeated filter() calls.
*
* @param props - Hook configuration
* @returns Functions to check deletion eligibility
*/
export function useCanDelete({ workspaceId }: UseCanDeleteProps): UseCanDeleteReturn {
const { data: workflowList = [] } = useWorkflows(workspaceId)
const { data: folders = {} } = useFolderMap(workspaceId)
/**
* Pre-computed data structures for efficient lookups
*/
const { totalWorkflows, workflowIdSet, workflowsByFolderId, childFoldersByParentId } =
useMemo(() => {
const workspaceWorkflows = workflowList.filter((w) => w.workspaceId === workspaceId)
const idSet = new Set(workspaceWorkflows.map((w) => w.id))
const byFolderId = new Map<string, number>()
for (const w of workspaceWorkflows) {
if (w.folderId) {
byFolderId.set(w.folderId, (byFolderId.get(w.folderId) || 0) + 1)
}
}
const childrenByParent = new Map<string, string[]>()
for (const folder of Object.values(folders)) {
if (folder.workspaceId === workspaceId && folder.parentId) {
const children = childrenByParent.get(folder.parentId) || []
children.push(folder.id)
childrenByParent.set(folder.parentId, children)
}
}
return {
totalWorkflows: workspaceWorkflows.length,
workflowIdSet: idSet,
workflowsByFolderId: byFolderId,
childFoldersByParentId: childrenByParent,
}
}, [workflowList, folders, workspaceId])
/**
* Count workflows in a folder and all its subfolders recursively.
* Uses pre-computed maps for efficient lookups.
*/
const countWorkflowsInFolder = useCallback(
(folderId: string): number => {
let count = workflowsByFolderId.get(folderId) || 0
const childFolders = childFoldersByParentId.get(folderId)
if (childFolders) {
for (const childId of childFolders) {
count += countWorkflowsInFolder(childId)
}
}
return count
},
[workflowsByFolderId, childFoldersByParentId]
)
/**
* Check if the given workflow IDs can be deleted.
* Returns false if deleting would remove all workflows from the workspace.
*/
const canDeleteWorkflows = useCallback(
(workflowIds: string[]): boolean => {
const workflowsToDelete = workflowIds.filter((id) => workflowIdSet.has(id)).length
// Must have at least one workflow remaining after deletion
return totalWorkflows > 0 && workflowsToDelete < totalWorkflows
},
[totalWorkflows, workflowIdSet]
)
/**
* Check if the given folder can be deleted.
* Empty folders are always deletable. Folders containing all workspace workflows are not.
*/
const canDeleteFolder = useCallback(
(folderId: string): boolean => {
const workflowsInFolder = countWorkflowsInFolder(folderId)
if (workflowsInFolder === 0) return true
return workflowsInFolder < totalWorkflows
},
[totalWorkflows, countWorkflowsInFolder]
)
return {
canDeleteWorkflows,
canDeleteFolder,
totalWorkflows,
}
}
@@ -0,0 +1,70 @@
import { useCallback, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useDeleteFolderMutation } from '@/hooks/queries/folders'
import { useFolderStore } from '@/stores/folders/store'
const logger = createLogger('useDeleteFolder')
interface UseDeleteFolderProps {
/**
* Current workspace ID
*/
workspaceId: string
/**
* The folder ID(s) to delete
*/
folderIds: string | string[]
/**
* Optional callback after successful deletion
*/
onSuccess?: () => void
}
/**
* Hook for managing folder deletion.
*
* @param props - Hook configuration
* @returns Delete folder handlers and state
*/
export function useDeleteFolder({ workspaceId, folderIds, onSuccess }: UseDeleteFolderProps) {
const deleteFolderMutation = useDeleteFolderMutation()
const [isDeleting, setIsDeleting] = useState(false)
/**
* Delete the folder(s)
*/
const handleDeleteFolder = useCallback(async () => {
if (isDeleting) {
return
}
if (!folderIds) {
return
}
setIsDeleting(true)
try {
const folderIdsToDelete = Array.isArray(folderIds) ? folderIds : [folderIds]
for (const folderId of folderIdsToDelete) {
await deleteFolderMutation.mutateAsync({ id: folderId, workspaceId })
}
const { clearSelection } = useFolderStore.getState()
clearSelection()
logger.info('Folder(s) deleted successfully', { folderIds: folderIdsToDelete })
onSuccess?.()
} catch (error) {
logger.error('Error deleting folder(s):', { error })
throw error
} finally {
setIsDeleting(false)
}
}, [folderIds, isDeleting, deleteFolderMutation, workspaceId, onSuccess])
return {
isDeleting,
handleDeleteFolder,
}
}
@@ -0,0 +1,169 @@
import { useCallback, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useRouter } from 'next/navigation'
import { useDeleteFolderMutation } from '@/hooks/queries/folders'
import { useDeleteWorkflowMutation, useWorkflows } from '@/hooks/queries/workflows'
import { useFolderStore } from '@/stores/folders/store'
const logger = createLogger('useDeleteSelection')
interface UseDeleteSelectionProps {
/**
* Current workspace ID
*/
workspaceId: string
/**
* Workflow IDs to delete
*/
workflowIds: string[]
/**
* Folder IDs to delete
*/
folderIds: string[]
/**
* Function to check if a workflow ID is the active workflow
*/
isActiveWorkflow?: (id: string) => boolean
/**
* Optional callback after successful deletion
*/
onSuccess?: () => void
}
/**
* Hook for managing unified deletion of workflows and folders.
* Handles mixed selection by deleting folders first (which may contain workflows),
* then deleting standalone workflows.
*
* @param props - Hook configuration
* @returns Delete selection handlers and state
*/
export function useDeleteSelection({
workspaceId,
workflowIds,
folderIds,
isActiveWorkflow,
onSuccess,
}: UseDeleteSelectionProps) {
const router = useRouter()
const { data: workflowList = [] } = useWorkflows(workspaceId)
const deleteWorkflowMutation = useDeleteWorkflowMutation()
const deleteFolderMutation = useDeleteFolderMutation()
const [isDeleting, setIsDeleting] = useState(false)
/**
* Delete all selected folders and workflows
*/
const handleDeleteSelection = useCallback(async () => {
if (isDeleting) {
return
}
const hasWorkflows = workflowIds.length > 0
const hasFolders = folderIds.length > 0
if (!hasWorkflows && !hasFolders) {
return
}
setIsDeleting(true)
try {
const activeWorkflowBeingDeleted = isActiveWorkflow
? workflowIds.some((id) => isActiveWorkflow(id))
: false
const sidebarWorkflows = workflowList.filter((w) => w.workspaceId === workspaceId)
const workflowsInFolders = sidebarWorkflows
.filter((w) => w.folderId && folderIds.includes(w.folderId))
.map((w) => w.id)
const allWorkflowsToDelete = [...new Set([...workflowIds, ...workflowsInFolders])]
const activeInDeletedFolder = isActiveWorkflow
? workflowsInFolders.some((id) => isActiveWorkflow(id))
: false
const needsNavigation = activeWorkflowBeingDeleted || activeInDeletedFolder
let nextWorkflowId: string | null = null
if (needsNavigation && sidebarWorkflows.length > allWorkflowsToDelete.length) {
const remainingWorkflows = sidebarWorkflows.filter(
(w) => !allWorkflowsToDelete.includes(w.id)
)
if (remainingWorkflows.length > 0) {
const activeId = isActiveWorkflow
? workflowIds.find((id) => isActiveWorkflow(id)) ||
workflowsInFolders.find((id) => isActiveWorkflow(id))
: null
if (activeId) {
const currentIndex = sidebarWorkflows.findIndex((w) => w.id === activeId)
const workflowsAfterCurrent = remainingWorkflows.filter((w) => {
const idx = sidebarWorkflows.findIndex((sw) => sw.id === w.id)
return idx > currentIndex
})
nextWorkflowId =
workflowsAfterCurrent.length > 0
? workflowsAfterCurrent[0].id
: remainingWorkflows[0].id
} else {
nextWorkflowId = remainingWorkflows[0].id
}
}
}
if (needsNavigation) {
if (nextWorkflowId) {
router.push(`/workspace/${workspaceId}/w/${nextWorkflowId}`)
} else {
router.push(`/workspace/${workspaceId}/home`)
}
}
for (const folderId of folderIds) {
await deleteFolderMutation.mutateAsync({ id: folderId, workspaceId })
}
const standaloneWorkflowIds = workflowIds.filter((id) => !workflowsInFolders.includes(id))
await Promise.all(
standaloneWorkflowIds.map((id) =>
deleteWorkflowMutation.mutateAsync({ workspaceId, workflowId: id })
)
)
const { clearSelection, clearFolderSelection } = useFolderStore.getState()
clearSelection()
clearFolderSelection()
logger.info('Selection deleted successfully', {
workflowIds: standaloneWorkflowIds,
folderIds,
totalWorkflowsDeleted: allWorkflowsToDelete.length,
})
onSuccess?.()
} catch (error) {
logger.error('Error deleting selection:', { error })
throw error
} finally {
setIsDeleting(false)
}
}, [
workflowIds,
folderIds,
isDeleting,
workflowList,
workspaceId,
isActiveWorkflow,
router,
onSuccess,
])
return {
isDeleting,
handleDeleteSelection,
}
}
@@ -0,0 +1,137 @@
import { useCallback, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useRouter } from 'next/navigation'
import { useDeleteWorkflowMutation, useWorkflows } from '@/hooks/queries/workflows'
import { useFolderStore } from '@/stores/folders/store'
const logger = createLogger('useDeleteWorkflow')
interface UseDeleteWorkflowProps {
/**
* Current workspace ID
*/
workspaceId: string
/**
* Workflow ID(s) to delete
*/
workflowIds: string | string[]
/**
* Whether the active workflow is being deleted
* Can be a boolean or a function that receives the workflow IDs
*/
isActive?: boolean | ((workflowIds: string[]) => boolean)
/**
* Optional callback after successful deletion
*/
onSuccess?: () => void
}
/**
* Hook for managing workflow deletion with navigation logic.
*
* @param props - Hook configuration
* @returns Delete workflow handlers and state
*/
export function useDeleteWorkflow({
workspaceId,
workflowIds,
isActive = false,
onSuccess,
}: UseDeleteWorkflowProps) {
const router = useRouter()
const { data: workflowList = [] } = useWorkflows(workspaceId)
const deleteWorkflowMutation = useDeleteWorkflowMutation()
const [isDeleting, setIsDeleting] = useState(false)
/**
* Delete the workflow(s) and navigate if needed
*/
const handleDeleteWorkflow = useCallback(async () => {
if (isDeleting) {
return
}
if (!workflowIds) {
return
}
setIsDeleting(true)
try {
const workflowIdsToDelete = Array.isArray(workflowIds) ? workflowIds : [workflowIds]
const isActiveWorkflowBeingDeleted =
typeof isActive === 'function' ? isActive(workflowIdsToDelete) : isActive
const sidebarWorkflows = workflowList.filter((w) => w.workspaceId === workspaceId)
let activeWorkflowId: string | null = null
if (isActiveWorkflowBeingDeleted && typeof isActive === 'function') {
activeWorkflowId =
workflowIdsToDelete.find((id) => isActive([id])) || workflowIdsToDelete[0]
} else {
activeWorkflowId = workflowIdsToDelete[0]
}
const currentIndex = sidebarWorkflows.findIndex((w) => w.id === activeWorkflowId)
let nextWorkflowId: string | null = null
if (isActiveWorkflowBeingDeleted && sidebarWorkflows.length > workflowIdsToDelete.length) {
const remainingWorkflows = sidebarWorkflows.filter(
(w) => !workflowIdsToDelete.includes(w.id)
)
if (remainingWorkflows.length > 0) {
const workflowsAfterCurrent = remainingWorkflows.filter((w) => {
const idx = sidebarWorkflows.findIndex((sw) => sw.id === w.id)
return idx > currentIndex
})
if (workflowsAfterCurrent.length > 0) {
nextWorkflowId = workflowsAfterCurrent[0].id
} else {
nextWorkflowId = remainingWorkflows[0].id
}
}
}
if (isActiveWorkflowBeingDeleted) {
if (nextWorkflowId) {
router.push(`/workspace/${workspaceId}/w/${nextWorkflowId}`)
} else {
router.push(`/workspace/${workspaceId}/home`)
}
}
await Promise.all(
workflowIdsToDelete.map((id) =>
deleteWorkflowMutation.mutateAsync({ workspaceId, workflowId: id })
)
)
const { clearSelection } = useFolderStore.getState()
clearSelection()
logger.info('Workflow(s) deleted successfully', { workflowIds: workflowIdsToDelete })
onSuccess?.()
} catch (error) {
logger.error('Error deleting workflow(s):', { error })
throw error
} finally {
setIsDeleting(false)
}
}, [
workflowIds,
isDeleting,
workflowList,
workspaceId,
isActive,
router,
deleteWorkflowMutation,
onSuccess,
])
return {
isDeleting,
handleDeleteWorkflow,
}
}
@@ -0,0 +1,119 @@
import { useCallback, useState } from 'react'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { getChildFolders, getFolderById } from '@/lib/folders/tree'
import { useDuplicateFolderMutation } from '@/hooks/queries/folders'
import { getFolderMap } from '@/hooks/queries/utils/folder-cache'
import { useFolderStore } from '@/stores/folders/store'
const logger = createLogger('useDuplicateFolder')
interface UseDuplicateFolderProps {
workspaceId: string
/**
* The folder ID(s) to duplicate
*/
folderIds: string | string[]
onSuccess?: () => void
}
/**
* Hook for managing folder duplication.
*
* @param props - Hook configuration
* @returns Duplicate folder handlers and state
*/
export function useDuplicateFolder({ workspaceId, folderIds, onSuccess }: UseDuplicateFolderProps) {
const duplicateFolderMutation = useDuplicateFolderMutation()
const [isDuplicating, setIsDuplicating] = useState(false)
const generateDuplicateName = useCallback((baseName: string, siblingNames: Set<string>) => {
const trimmedName = (baseName || 'Untitled Folder').trim()
let candidate = `${trimmedName} Copy`
let counter = 2
while (siblingNames.has(candidate)) {
candidate = `${trimmedName} Copy ${counter}`
counter += 1
}
return candidate
}, [])
/**
* Duplicate the folder(s)
*/
const handleDuplicateFolder = useCallback(async () => {
if (isDuplicating) {
return
}
if (!folderIds) {
return
}
setIsDuplicating(true)
try {
const folderIdsToDuplicate = Array.isArray(folderIds) ? folderIds : [folderIds]
const duplicatedIds: string[] = []
const folderMap = getFolderMap(workspaceId)
for (const folderId of folderIdsToDuplicate) {
const folder = getFolderById(folderMap, folderId)
if (!folder) {
logger.warn('Attempted to duplicate folder that no longer exists', { folderId })
continue
}
const siblingNames = new Set(
getChildFolders(folderMap, folder.parentId).map((sibling) => sibling.name)
)
siblingNames.add(folder.name)
const duplicateName = generateDuplicateName(folder.name, siblingNames)
const result = await duplicateFolderMutation.mutateAsync({
id: folderId,
workspaceId,
name: duplicateName,
parentId: folder.parentId,
color: folder.color,
newId: generateId(),
})
const newFolderId = result?.id
if (newFolderId) {
duplicatedIds.push(newFolderId)
}
}
const { clearSelection } = useFolderStore.getState()
clearSelection()
logger.info('Folder(s) duplicated successfully', {
folderIds: folderIdsToDuplicate,
duplicatedIds,
})
onSuccess?.()
} catch (error) {
logger.error('Error duplicating folder(s):', { error })
throw error
} finally {
setIsDuplicating(false)
}
}, [
folderIds,
generateDuplicateName,
isDuplicating,
duplicateFolderMutation,
workspaceId,
onSuccess,
])
return {
isDuplicating,
handleDuplicateFolder,
}
}
@@ -0,0 +1,156 @@
import { useCallback, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { useRouter } from 'next/navigation'
import { getChildFolders, getFolderById } from '@/lib/folders/tree'
import { useDuplicateFolderMutation } from '@/hooks/queries/folders'
import { getFolderMap } from '@/hooks/queries/utils/folder-cache'
import { getWorkflows } from '@/hooks/queries/utils/workflow-cache'
import { useDuplicateWorkflowMutation } from '@/hooks/queries/workflows'
import { useFolderStore } from '@/stores/folders/store'
const logger = createLogger('useDuplicateSelection')
interface UseDuplicateSelectionProps {
/**
* Current workspace ID
*/
workspaceId: string
/**
* Optional callback after successful duplication
*/
onSuccess?: () => void
}
/**
* Hook for managing unified duplication of workflows and folders.
* Handles mixed selection by duplicating all selected items.
*
* @param props - Hook configuration
* @returns Duplicate selection handlers and state
*/
export function useDuplicateSelection({ workspaceId, onSuccess }: UseDuplicateSelectionProps) {
const router = useRouter()
const duplicateWorkflowMutation = useDuplicateWorkflowMutation()
const duplicateFolderMutation = useDuplicateFolderMutation()
const [isDuplicating, setIsDuplicating] = useState(false)
const workspaceIdRef = useRef(workspaceId)
workspaceIdRef.current = workspaceId
const onSuccessRef = useRef(onSuccess)
onSuccessRef.current = onSuccess
const generateDuplicateFolderName = useCallback((baseName: string, siblingNames: Set<string>) => {
const trimmedName = (baseName || 'Untitled Folder').trim()
let candidate = `${trimmedName} Copy`
let counter = 2
while (siblingNames.has(candidate)) {
candidate = `${trimmedName} Copy ${counter}`
counter += 1
}
return candidate
}, [])
/**
* Duplicate all selected workflows and folders
*/
const handleDuplicateSelection = useCallback(
async (workflowIds: string[], folderIds: string[]) => {
if (isDuplicating) return
if (workflowIds.length === 0 && folderIds.length === 0) return
setIsDuplicating(true)
try {
const workflowMap = new Map(getWorkflows(workspaceIdRef.current).map((w) => [w.id, w]))
const folderMap = getFolderMap(workspaceIdRef.current)
const duplicatedWorkflowIds: string[] = []
const duplicatedFolderIds: string[] = []
for (const folderId of folderIds) {
const folder = getFolderById(folderMap, folderId)
if (!folder) {
logger.warn(`Folder ${folderId} not found, skipping`)
continue
}
const siblingNames = new Set(
getChildFolders(folderMap, folder.parentId).map((sibling) => sibling.name)
)
siblingNames.add(folder.name)
const duplicateName = generateDuplicateFolderName(folder.name, siblingNames)
const result = await duplicateFolderMutation.mutateAsync({
id: folderId,
workspaceId: workspaceIdRef.current,
name: duplicateName,
parentId: folder.parentId,
color: folder.color,
newId: generateId(),
})
if (result?.id) {
duplicatedFolderIds.push(result.id)
}
}
for (const workflowId of workflowIds) {
const workflow = workflowMap.get(workflowId)
if (!workflow) {
logger.warn(`Workflow ${workflowId} not found, skipping`)
continue
}
const result = await duplicateWorkflowMutation.mutateAsync({
workspaceId: workspaceIdRef.current,
sourceId: workflowId,
name: `${workflow.name} (Copy)`,
description: workflow.description,
folderId: workflow.folderId,
newId: generateId(),
})
duplicatedWorkflowIds.push(result.id)
}
const { clearSelection, clearFolderSelection } = useFolderStore.getState()
clearSelection()
clearFolderSelection()
logger.info('Selection duplicated successfully', {
workflowIds,
folderIds,
duplicatedWorkflowIds,
duplicatedFolderIds,
})
if (duplicatedWorkflowIds.length === 1 && duplicatedFolderIds.length === 0) {
router.push(`/workspace/${workspaceIdRef.current}/w/${duplicatedWorkflowIds[0]}`)
}
onSuccessRef.current?.()
} catch (error) {
logger.error('Error duplicating selection:', { error })
throw error
} finally {
setIsDuplicating(false)
}
},
[
isDuplicating,
generateDuplicateFolderName,
duplicateFolderMutation,
duplicateWorkflowMutation,
router,
]
)
return {
isDuplicating,
handleDuplicateSelection,
}
}
@@ -0,0 +1,110 @@
import { useCallback, useRef } from 'react'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { useRouter } from 'next/navigation'
import { getWorkflows } from '@/hooks/queries/utils/workflow-cache'
import { useDuplicateWorkflowMutation } from '@/hooks/queries/workflows'
import { useFolderStore } from '@/stores/folders/store'
const logger = createLogger('useDuplicateWorkflow')
interface UseDuplicateWorkflowProps {
/**
* Current workspace ID
*/
workspaceId: string
/**
* Optional callback after successful duplication
*/
onSuccess?: () => void
}
/**
* Hook for managing workflow duplication with optimistic updates.
*
* @param props - Hook configuration
* @returns Duplicate workflow handlers and state
*/
export function useDuplicateWorkflow({ workspaceId, onSuccess }: UseDuplicateWorkflowProps) {
const router = useRouter()
const duplicateMutation = useDuplicateWorkflowMutation()
const workspaceIdRef = useRef(workspaceId)
workspaceIdRef.current = workspaceId
const onSuccessRef = useRef(onSuccess)
onSuccessRef.current = onSuccess
/**
* Store a ref to the mutation to access isPending without causing callback recreation.
* The mutateAsync function from React Query is already stable.
*/
const mutationRef = useRef(duplicateMutation)
mutationRef.current = duplicateMutation
/**
* Duplicate the workflow(s)
* @param workflowIds - The workflow ID(s) to duplicate
*/
const handleDuplicateWorkflow = useCallback(
async (workflowIds: string | string[]) => {
if (!workflowIds || (Array.isArray(workflowIds) && workflowIds.length === 0)) {
return
}
if (mutationRef.current.isPending) {
return
}
const workflowIdsToDuplicate = Array.isArray(workflowIds) ? workflowIds : [workflowIds]
const duplicatedIds: string[] = []
try {
const workflowMap = new Map(getWorkflows(workspaceIdRef.current).map((w) => [w.id, w]))
for (const sourceId of workflowIdsToDuplicate) {
const sourceWorkflow = workflowMap.get(sourceId)
if (!sourceWorkflow) {
logger.warn(`Workflow ${sourceId} not found, skipping`)
continue
}
const result = await mutationRef.current.mutateAsync({
workspaceId: workspaceIdRef.current,
sourceId,
name: `${sourceWorkflow.name} (Copy)`,
description: sourceWorkflow.description,
folderId: sourceWorkflow.folderId,
newId: generateId(),
})
duplicatedIds.push(result.id)
}
const { clearSelection } = useFolderStore.getState()
clearSelection()
logger.info('Workflow(s) duplicated successfully', {
workflowIds: workflowIdsToDuplicate,
duplicatedIds,
})
if (duplicatedIds.length === 1) {
router.push(`/workspace/${workspaceIdRef.current}/w/${duplicatedIds[0]}`)
}
onSuccessRef.current?.()
} catch (error) {
logger.error('Error duplicating workflow(s):', { error })
throw error
}
},
[router]
)
return {
isDuplicating: duplicateMutation.isPending,
handleDuplicateWorkflow,
}
}
@@ -0,0 +1,197 @@
import { useCallback, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { getFolderById } from '@/lib/folders/tree'
import {
downloadFile,
exportFolderToZip,
type FolderExportData,
fetchWorkflowForExport,
sanitizePathSegment,
type WorkflowExportData,
} from '@/lib/workflows/operations/import-export'
import { useFolderMap } from '@/hooks/queries/folders'
import { useWorkflowMap } from '@/hooks/queries/workflows'
import { useFolderStore } from '@/stores/folders/store'
import type { WorkflowFolder } from '@/stores/folders/types'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
const logger = createLogger('useExportFolder')
interface UseExportFolderProps {
/**
* Active workspace id
*/
workspaceId: string | undefined
/**
* The folder ID to export
*/
folderId: string
/**
* Optional callback after successful export
*/
onSuccess?: () => void
}
interface CollectedWorkflow {
id: string
folderId: string | null
}
/**
* Recursively collects all workflows within a folder and its subfolders.
*/
function collectWorkflowsInFolder(
folderId: string,
workflows: Record<string, WorkflowMetadata>,
folders: Record<string, WorkflowFolder>
): CollectedWorkflow[] {
const collectedWorkflows: CollectedWorkflow[] = []
for (const workflow of Object.values(workflows)) {
if (workflow.folderId === folderId) {
collectedWorkflows.push({ id: workflow.id, folderId: workflow.folderId ?? null })
}
}
for (const folder of Object.values(folders)) {
if (folder.parentId === folderId) {
const childWorkflows = collectWorkflowsInFolder(folder.id, workflows, folders)
collectedWorkflows.push(...childWorkflows)
}
}
return collectedWorkflows
}
/**
* Collects all subfolders recursively under a root folder.
* Returns folders with parentId adjusted so direct children of rootFolderId have parentId: null.
*/
function collectSubfolders(
rootFolderId: string,
folders: Record<string, WorkflowFolder>
): FolderExportData[] {
const subfolders: FolderExportData[] = []
function collect(parentId: string) {
for (const folder of Object.values(folders)) {
if (folder.parentId === parentId) {
subfolders.push({
id: folder.id,
name: folder.name,
// Direct children of root become top-level in export (parentId: null)
parentId: folder.parentId === rootFolderId ? null : folder.parentId,
})
collect(folder.id)
}
}
}
collect(rootFolderId)
return subfolders
}
/**
* Hook for managing folder export to ZIP.
*/
export function useExportFolder({ workspaceId, folderId, onSuccess }: UseExportFolderProps) {
const { data: workflows = {} } = useWorkflowMap(workspaceId)
const { data: folders = {} } = useFolderMap(workspaceId)
const [isExporting, setIsExporting] = useState(false)
const hasWorkflows = useMemo(() => {
if (!folderId) return false
return collectWorkflowsInFolder(folderId, workflows, folders).length > 0
}, [folderId, workflows, folders])
const handleExportFolder = useCallback(async () => {
if (isExporting || !folderId) {
return
}
setIsExporting(true)
try {
const folder = getFolderById(folders, folderId)
if (!folder) {
logger.warn('Folder not found for export', { folderId })
return
}
const workflowsToExport = collectWorkflowsInFolder(folderId, workflows, folders)
if (workflowsToExport.length === 0) {
logger.warn('No workflows found in folder to export', { folderId, folderName: folder.name })
return
}
const subfolders = collectSubfolders(folderId, folders)
logger.info('Starting folder export', {
folderId,
folderName: folder.name,
workflowCount: workflowsToExport.length,
subfolderCount: subfolders.length,
})
const workflowExportData: WorkflowExportData[] = []
for (const collectedWorkflow of workflowsToExport) {
const workflowMeta = workflows[collectedWorkflow.id]
if (!workflowMeta) {
logger.warn(`Workflow ${collectedWorkflow.id} not found in registry`)
continue
}
const remappedFolderId =
collectedWorkflow.folderId === folderId ? null : collectedWorkflow.folderId
const exportData = await fetchWorkflowForExport(collectedWorkflow.id, {
name: workflowMeta.name,
description: workflowMeta.description,
folderId: remappedFolderId,
})
if (exportData) {
workflowExportData.push(exportData)
logger.info(`Workflow ${collectedWorkflow.id} prepared for export`)
}
}
if (workflowExportData.length === 0) {
logger.warn('No workflows were successfully prepared for export', {
folderId,
folderName: folder.name,
})
return
}
const zipBlob = await exportFolderToZip(folder.name, workflowExportData, subfolders)
const zipFilename = `${sanitizePathSegment(folder.name)}-export.zip`
downloadFile(zipBlob, zipFilename, 'application/zip')
const { clearSelection } = useFolderStore.getState()
clearSelection()
logger.info('Folder exported successfully', {
folderId,
folderName: folder.name,
workflowCount: workflowExportData.length,
subfolderCount: subfolders.length,
})
onSuccess?.()
} catch (error) {
logger.error('Error exporting folder:', { error })
throw error
} finally {
setIsExporting(false)
}
}, [folderId, isExporting, workflows, folders, onSuccess])
return {
isExporting,
hasWorkflows,
handleExportFolder,
}
}
@@ -0,0 +1,217 @@
import { useCallback, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import {
downloadFile,
exportWorkflowsToZip,
type FolderExportData,
fetchWorkflowForExport,
type WorkflowExportData,
} from '@/lib/workflows/operations/import-export'
import { getFolderMap } from '@/hooks/queries/utils/folder-cache'
import { getWorkflows } from '@/hooks/queries/utils/workflow-cache'
import { useFolderStore } from '@/stores/folders/store'
import type { WorkflowFolder } from '@/stores/folders/types'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
const logger = createLogger('useExportSelection')
interface UseExportSelectionProps {
/**
* Active workspace id
*/
workspaceId: string | undefined
/**
* Optional callback after successful export
*/
onSuccess?: () => void
}
interface CollectedWorkflow {
id: string
folderId: string | null
}
/**
* Recursively collects all workflows within a folder and its subfolders.
*/
function collectWorkflowsInFolder(
folderId: string,
workflows: Record<string, WorkflowMetadata>,
folders: Record<string, WorkflowFolder>
): CollectedWorkflow[] {
const collectedWorkflows: CollectedWorkflow[] = []
for (const workflow of Object.values(workflows)) {
if (workflow.folderId === folderId) {
collectedWorkflows.push({ id: workflow.id, folderId: workflow.folderId ?? null })
}
}
for (const folder of Object.values(folders)) {
if (folder.parentId === folderId) {
const childWorkflows = collectWorkflowsInFolder(folder.id, workflows, folders)
collectedWorkflows.push(...childWorkflows)
}
}
return collectedWorkflows
}
/**
* Collects all subfolders recursively under multiple root folders.
*/
function collectSubfoldersForMultipleFolders(
rootFolderIds: string[],
folders: Record<string, WorkflowFolder>
): FolderExportData[] {
const subfolders: FolderExportData[] = []
function collect(parentId: string, isRootLevel: boolean) {
for (const folder of Object.values(folders)) {
if (folder.parentId === parentId) {
subfolders.push({
id: folder.id,
name: folder.name,
parentId: isRootLevel ? null : folder.parentId,
})
collect(folder.id, false)
}
}
}
for (const folderId of rootFolderIds) {
collect(folderId, true)
}
return subfolders
}
/**
* Hook for managing unified export of workflows and folders.
* Handles mixed selection by collecting all workflows from selected folders
* and combining with directly selected workflows.
*/
export function useExportSelection({ workspaceId, onSuccess }: UseExportSelectionProps) {
const [isExporting, setIsExporting] = useState(false)
const onSuccessRef = useRef(onSuccess)
onSuccessRef.current = onSuccess
const workspaceIdRef = useRef(workspaceId)
workspaceIdRef.current = workspaceId
/**
* Export all selected workflows and folders to a ZIP file.
* - Collects workflows from selected folders recursively
* - Deduplicates workflows that exist in both direct selection and folder contents
* - Preserves folder structure in the export
*/
const handleExportSelection = useCallback(
async (workflowIds: string[], folderIds: string[]) => {
if (isExporting) {
return
}
const hasWorkflows = workflowIds.length > 0
const hasFolders = folderIds.length > 0
if (!hasWorkflows && !hasFolders) {
return
}
setIsExporting(true)
try {
if (!workspaceIdRef.current) return
const workflowsArray = getWorkflows(workspaceIdRef.current)
const workflows = Object.fromEntries(workflowsArray.map((w) => [w.id, w]))
const folderMap = getFolderMap(workspaceIdRef.current)
const workflowsFromFolders: CollectedWorkflow[] = []
for (const folderId of folderIds) {
const collected = collectWorkflowsInFolder(folderId, workflows, folderMap)
workflowsFromFolders.push(...collected)
}
const subfolders = collectSubfoldersForMultipleFolders(folderIds, folderMap)
const selectedFoldersData: FolderExportData[] = folderIds
.filter((folderId) => folderMap[folderId])
.map((folderId) => {
const folder = folderMap[folderId]
return {
id: folder.id,
name: folder.name,
parentId: null,
}
})
const allFolders = [...selectedFoldersData, ...subfolders]
const workflowIdsFromFolders = workflowsFromFolders.map((w) => w.id)
const allWorkflowIds = [...new Set([...workflowIds, ...workflowIdsFromFolders])]
if (allWorkflowIds.length === 0) {
logger.warn('No workflows found to export')
return
}
logger.info('Starting selection export', {
directWorkflowCount: workflowIds.length,
folderCount: folderIds.length,
totalWorkflowCount: allWorkflowIds.length,
subfolderCount: subfolders.length,
})
const workflowExportData: WorkflowExportData[] = []
for (const workflowId of allWorkflowIds) {
const workflowMeta = workflows[workflowId]
if (!workflowMeta) {
logger.warn(`Workflow ${workflowId} not found in registry`)
continue
}
const exportData = await fetchWorkflowForExport(workflowId, {
name: workflowMeta.name,
description: workflowMeta.description,
folderId: workflowMeta.folderId ?? null,
})
if (exportData) {
workflowExportData.push(exportData)
logger.info(`Workflow ${workflowId} prepared for export`)
}
}
if (workflowExportData.length === 0) {
logger.warn('No workflows were successfully prepared for export')
return
}
const zipBlob = await exportWorkflowsToZip(workflowExportData)
const zipFilename = `selection-export-${Date.now()}.zip`
downloadFile(zipBlob, zipFilename, 'application/zip')
const { clearSelection, clearFolderSelection } = useFolderStore.getState()
clearSelection()
clearFolderSelection()
logger.info('Selection exported successfully', {
workflowCount: workflowExportData.length,
folderCount: allFolders.length,
})
onSuccessRef.current?.()
} catch (error) {
logger.error('Error exporting selection:', { error })
throw error
} finally {
setIsExporting(false)
}
},
[isExporting]
)
return {
isExporting,
handleExportSelection,
}
}
@@ -0,0 +1,136 @@
import { useCallback, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { usePostHog } from 'posthog-js/react'
import { captureEvent } from '@/lib/posthog/client'
import {
downloadFile,
exportWorkflowsToZip,
exportWorkflowToJson,
fetchWorkflowForExport,
sanitizePathSegment,
} from '@/lib/workflows/operations/import-export'
import { getWorkflows } from '@/hooks/queries/utils/workflow-cache'
import { useFolderStore } from '@/stores/folders/store'
const logger = createLogger('useExportWorkflow')
interface UseExportWorkflowProps {
/**
* Active workspace id
*/
workspaceId: string | undefined
/**
* Optional callback after successful export
*/
onSuccess?: () => void
}
/**
* Hook for managing workflow export to JSON or ZIP.
*/
export function useExportWorkflow({ workspaceId, onSuccess }: UseExportWorkflowProps) {
const [isExporting, setIsExporting] = useState(false)
const posthog = usePostHog()
const onSuccessRef = useRef(onSuccess)
onSuccessRef.current = onSuccess
const workspaceIdRef = useRef(workspaceId)
workspaceIdRef.current = workspaceId
const posthogRef = useRef(posthog)
posthogRef.current = posthog
/**
* Export the workflow(s) to JSON or ZIP
* - Single workflow: exports as JSON file
* - Multiple workflows: exports as ZIP file containing all JSON files
*/
const handleExportWorkflow = useCallback(
async (workflowIds: string | string[]) => {
if (isExporting) {
return
}
if (!workflowIds || (Array.isArray(workflowIds) && workflowIds.length === 0)) {
return
}
setIsExporting(true)
try {
const workflowIdsToExport = Array.isArray(workflowIds) ? workflowIds : [workflowIds]
logger.info('Starting workflow export', {
workflowIdsToExport,
count: workflowIdsToExport.length,
})
if (!workspaceIdRef.current) return
const workflowMap = new Map(getWorkflows(workspaceIdRef.current).map((w) => [w.id, w]))
const exportedWorkflows = []
for (const workflowId of workflowIdsToExport) {
const workflowMeta = workflowMap.get(workflowId)
if (!workflowMeta) {
logger.warn(`Workflow ${workflowId} not found in registry`)
continue
}
const exportData = await fetchWorkflowForExport(workflowId, {
name: workflowMeta.name,
description: workflowMeta.description,
folderId: workflowMeta.folderId,
})
if (exportData) {
exportedWorkflows.push(exportData)
logger.info(`Workflow ${workflowId} prepared for export`)
}
}
if (exportedWorkflows.length === 0) {
logger.warn('No workflows were successfully prepared for export')
return
}
if (exportedWorkflows.length === 1) {
const jsonContent = exportWorkflowToJson(exportedWorkflows[0])
const filename = `${sanitizePathSegment(exportedWorkflows[0].workflow.name)}.json`
downloadFile(jsonContent, filename, 'application/json')
} else {
const zipBlob = await exportWorkflowsToZip(exportedWorkflows)
const zipFilename = `workflows-export-${Date.now()}.zip`
downloadFile(zipBlob, zipFilename, 'application/zip')
}
const { clearSelection } = useFolderStore.getState()
clearSelection()
captureEvent(posthogRef.current, 'workflow_exported', {
workspace_id: workspaceIdRef.current ?? '',
workflow_count: exportedWorkflows.length,
format: exportedWorkflows.length === 1 ? 'json' : 'zip',
})
logger.info('Workflow(s) exported successfully', {
workflowIds: workflowIdsToExport,
count: exportedWorkflows.length,
format: exportedWorkflows.length === 1 ? 'JSON' : 'ZIP',
})
onSuccessRef.current?.()
} catch (error) {
logger.error('Error exporting workflow(s):', { error })
throw error
} finally {
setIsExporting(false)
}
},
[isExporting]
)
return {
isExporting,
handleExportWorkflow,
}
}
@@ -0,0 +1,237 @@
import { useCallback, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { useRouter } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
import { captureEvent } from '@/lib/posthog/client'
import {
extractWorkflowsFromFiles,
extractWorkflowsFromZip,
persistImportedWorkflow,
sanitizePathSegment,
} from '@/lib/workflows/operations/import-export'
import { useCreateFolder } from '@/hooks/queries/folders'
import { folderKeys } from '@/hooks/queries/utils/folder-keys'
import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists'
import { useCreateWorkflow } from '@/hooks/queries/workflows'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
const logger = createLogger('useImportWorkflow')
interface UseImportWorkflowProps {
workspaceId: string
}
/**
* Custom hook to handle workflow import functionality.
* Supports importing from:
* - Single JSON file
* - Multiple JSON files
* - ZIP file containing multiple workflows with folder structure
*
* @param props - Configuration object containing workspaceId
* @returns Import state and handlers
*/
export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
const router = useRouter()
const createWorkflowMutation = useCreateWorkflow()
const queryClient = useQueryClient()
const createFolderMutation = useCreateFolder()
const clearDiff = useWorkflowDiffStore((state) => state.clearDiff)
const posthog = usePostHog()
const posthogRef = useRef(posthog)
posthogRef.current = posthog
const [isImporting, setIsImporting] = useState(false)
/**
* Import a single workflow
*/
const importSingleWorkflow = useCallback(
async (content: string, filename: string, folderId?: string, sortOrder?: number) => {
clearDiff()
const result = await persistImportedWorkflow({
content,
filename,
workspaceId,
folderId,
sortOrder,
createWorkflow: async ({ name, description, workspaceId, folderId, sortOrder }) =>
createWorkflowMutation.mutateAsync({
name,
description,
workspaceId,
folderId,
sortOrder,
deduplicate: true,
}),
})
return result?.workflowId ?? null
},
[clearDiff, createWorkflowMutation, workspaceId]
)
/**
* Handle file selection and read
*/
const handleFileChange = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files
if (!files || files.length === 0) return
setIsImporting(true)
try {
const fileArray = Array.from(files)
const hasZip = fileArray.some((f) => f.name.toLowerCase().endsWith('.zip'))
const jsonFiles = fileArray.filter((f) => f.name.toLowerCase().endsWith('.json'))
const importedWorkflowIds: string[] = []
if (hasZip && fileArray.length === 1) {
const zipFile = fileArray[0]
const { workflows: extractedWorkflows, metadata } = await extractWorkflowsFromZip(zipFile)
const folderName = metadata?.workspaceName || zipFile.name.replace(/\.zip$/i, '')
const importFolder = await createFolderMutation.mutateAsync({
name: folderName,
workspaceId,
})
const folderMap = new Map<string, string>()
if (metadata?.folders && metadata.folders.length > 0) {
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 oldIdToNewId = new Map<string, string>()
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(sanitizePathSegment(folder.name))
currentId = folder.parentId
}
return pathParts.join('/')
}
const createFolderRecursive = async (folder: ExportedFolder): Promise<string> => {
if (oldIdToNewId.has(folder.id)) {
return oldIdToNewId.get(folder.id)!
}
let parentId = importFolder.id
if (folder.parentId && foldersById.has(folder.parentId)) {
parentId = await createFolderRecursive(foldersById.get(folder.parentId)!)
}
const newFolder = await createFolderMutation.mutateAsync({
name: folder.name,
workspaceId,
parentId,
sortOrder: folder.sortOrder,
})
oldIdToNewId.set(folder.id, newFolder.id)
folderMap.set(buildPath(folder.id), newFolder.id)
return newFolder.id
}
for (const folder of metadata.folders) {
await createFolderRecursive(folder)
}
}
for (const workflow of extractedWorkflows) {
try {
let targetFolderId = importFolder.id
if (workflow.folderPath.length > 0) {
const folderPathKey = workflow.folderPath.join('/')
if (folderMap.has(folderPathKey)) {
targetFolderId = folderMap.get(folderPathKey)!
} else {
let parentId = importFolder.id
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 subFolder = await createFolderMutation.mutateAsync({
name: folderNameForSegment,
workspaceId,
parentId,
})
folderMap.set(pathSegment, subFolder.id)
parentId = subFolder.id
} else {
parentId = folderMap.get(pathSegment)!
}
}
targetFolderId = folderMap.get(folderPathKey)!
}
}
const workflowId = await importSingleWorkflow(
workflow.content,
workflow.name,
targetFolderId,
workflow.sortOrder
)
if (workflowId) importedWorkflowIds.push(workflowId)
} catch (error) {
logger.error(`Failed to import ${workflow.name}:`, error)
}
}
} else if (jsonFiles.length > 0) {
const extractedWorkflows = await extractWorkflowsFromFiles(jsonFiles)
for (const workflow of extractedWorkflows) {
try {
const workflowId = await importSingleWorkflow(workflow.content, workflow.name)
if (workflowId) importedWorkflowIds.push(workflowId)
} catch (error) {
logger.error(`Failed to import ${workflow.name}:`, error)
}
}
}
await invalidateWorkflowLists(queryClient, workspaceId)
await queryClient.invalidateQueries({ queryKey: folderKeys.list(workspaceId) })
logger.info(`Import complete. Imported ${importedWorkflowIds.length} workflow(s)`)
if (importedWorkflowIds.length > 0) {
captureEvent(posthogRef.current, 'workflow_imported', {
workspace_id: workspaceId,
workflow_count: importedWorkflowIds.length,
format: hasZip && fileArray.length === 1 ? 'zip' : 'json',
})
router.push(
`/workspace/${workspaceId}/w/${importedWorkflowIds[importedWorkflowIds.length - 1]}`
)
}
} catch (error) {
logger.error('Failed to import workflows:', error)
} finally {
setIsImporting(false)
if (event.target) {
event.target.value = ''
}
}
},
[importSingleWorkflow, workspaceId, router, createFolderMutation, queryClient]
)
return {
isImporting,
handleFileChange,
}
}