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
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:
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user