Compare commits

..

4 Commits

Author SHA1 Message Date
waleed
e34a28c331 fixed flicker on importing multiple workflows 2026-01-11 21:15:56 -08:00
Emir Karabeg
f0f9c91ab0 improvement(import): loading animation 2026-01-11 21:06:12 -08:00
waleed
d0c0983e93 feat(export): added the ability to export workflow 2026-01-11 20:57:19 -08:00
Waleed
cb4db20a5f fix(color-picker): confirm color change before updating workflow color (#2776) 2026-01-11 18:47:48 -08:00
11 changed files with 383 additions and 41 deletions

View File

@@ -347,11 +347,11 @@ export function ContextMenu({
title={name}
onClick={(e) => {
e.stopPropagation()
onColorChange(color)
setHexInput(color)
}}
className={cn(
'h-[20px] w-[20px] rounded-[4px]',
currentColor?.toLowerCase() === color.toLowerCase() && 'ring-1 ring-white'
hexInput.toLowerCase() === color.toLowerCase() && 'ring-1 ring-white'
)}
style={{ backgroundColor: color }}
/>
@@ -373,7 +373,7 @@ export function ContextMenu({
onKeyDown={handleHexKeyDown}
onFocus={handleHexFocus}
onClick={(e) => e.stopPropagation()}
className='h-[20px] min-w-0 flex-1 rounded-[4px] bg-[#363636] px-[6px] text-[11px] text-white uppercase focus:outline-none'
className='h-[20px] min-w-0 flex-1 rounded-[4px] bg-[#363636] px-[6px] text-[11px] text-white uppercase caret-white focus:outline-none'
/>
<button
type='button'

View File

@@ -20,6 +20,7 @@ import {
useCanDelete,
useDeleteFolder,
useDuplicateFolder,
useExportFolder,
} from '@/app/workspace/[workspaceId]/w/hooks'
import { useCreateFolder, useUpdateFolder } from '@/hooks/queries/folders'
import { useCreateWorkflow } from '@/hooks/queries/workflows'
@@ -73,6 +74,12 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
getFolderIds: () => folder.id,
})
// Export folder hook
const { isExporting, hasWorkflows, handleExportFolder } = useExportFolder({
workspaceId,
getFolderId: () => folder.id,
})
// Folder expand hook - must be declared before callbacks that use expandFolder
const {
isExpanded,
@@ -365,13 +372,16 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
onCreate={handleCreateWorkflowInFolder}
onCreateFolder={handleCreateFolderInFolder}
onDuplicate={handleDuplicateFolder}
onExport={handleExportFolder}
onDelete={() => setIsDeleteModalOpen(true)}
showCreate={true}
showCreateFolder={true}
showExport={true}
disableRename={!userPermissions.canEdit}
disableCreate={!userPermissions.canEdit || createWorkflowMutation.isPending}
disableCreateFolder={!userPermissions.canEdit || createFolderMutation.isPending}
disableDuplicate={!userPermissions.canEdit}
disableDuplicate={!userPermissions.canEdit || !hasWorkflows}
disableExport={!userPermissions.canEdit || isExporting || !hasWorkflows}
disableDelete={!userPermissions.canEdit || !canDelete}
/>

View File

@@ -9,7 +9,6 @@ import {
useDragDrop,
useWorkflowSelection,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks/use-import-workflow'
import { useFolders } from '@/hooks/queries/folders'
import { useFolderStore } from '@/stores/folders/store'
import type { FolderTreeNode } from '@/stores/folders/types'
@@ -25,15 +24,13 @@ const TREE_SPACING = {
interface WorkflowListProps {
regularWorkflows: WorkflowMetadata[]
isLoading?: boolean
isImporting: boolean
setIsImporting: (value: boolean) => void
handleFileChange: (event: React.ChangeEvent<HTMLInputElement>) => void
fileInputRef: React.RefObject<HTMLInputElement | null>
scrollContainerRef: React.RefObject<HTMLDivElement | null>
}
/**
* WorkflowList component displays workflows organized by folders with drag-and-drop support.
* Uses the workflow import hook for handling JSON imports.
*
* @param props - Component props
* @returns Workflow list with folders and drag-drop support
@@ -41,8 +38,7 @@ interface WorkflowListProps {
export function WorkflowList({
regularWorkflows,
isLoading = false,
isImporting,
setIsImporting,
handleFileChange,
fileInputRef,
scrollContainerRef,
}: WorkflowListProps) {
@@ -65,9 +61,6 @@ export function WorkflowList({
createFolderHeaderHoverHandlers,
} = useDragDrop()
// Workflow import hook
const { handleFileChange } = useImportWorkflow({ workspaceId })
// Set scroll container when ref changes
useEffect(() => {
if (scrollContainerRef.current) {

View File

@@ -2,10 +2,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { ArrowDown, Database, HelpCircle, Layout, Plus, Search, Settings } from 'lucide-react'
import { Database, HelpCircle, Layout, Plus, Search, Settings } from 'lucide-react'
import Link from 'next/link'
import { useParams, usePathname, useRouter } from 'next/navigation'
import { Button, FolderPlus, Library, Tooltip } from '@/components/emcn'
import { Button, Download, FolderPlus, Library, Loader, Tooltip } from '@/components/emcn'
import { useSession } from '@/lib/auth/auth-client'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
@@ -30,6 +30,7 @@ import {
import {
useDuplicateWorkspace,
useExportWorkspace,
useImportWorkflow,
useImportWorkspace,
} from '@/app/workspace/[workspaceId]/w/hooks'
import { usePermissionConfig } from '@/hooks/use-permission-config'
@@ -85,9 +86,11 @@ export function Sidebar() {
const isCollapsed = hasHydrated ? isCollapsedStore : false
const isOnWorkflowPage = !!workflowId
const [isImporting, setIsImporting] = useState(false)
const workspaceFileInputRef = useRef<HTMLInputElement>(null)
const { isImporting, handleFileChange: handleImportFileChange } = useImportWorkflow({
workspaceId,
})
const { isImporting: isImportingWorkspace, handleImportWorkspace: importWorkspace } =
useImportWorkspace()
const { handleExportWorkspace: exportWorkspace } = useExportWorkspace()
@@ -565,21 +568,31 @@ export function Sidebar() {
Workflows
</div>
<div className='flex items-center justify-center gap-[10px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='translate-y-[-0.25px] p-[1px]'
onClick={handleImportWorkflow}
disabled={isImporting || !canEdit}
>
<ArrowDown className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>{isImporting ? 'Importing workflow...' : 'Import workflow'}</p>
</Tooltip.Content>
</Tooltip.Root>
{isImporting ? (
<Button
variant='ghost'
className='translate-y-[-0.25px] p-[1px]'
disabled={!canEdit || isImporting}
>
<Loader className='h-[14px] w-[14px]' animate />
</Button>
) : (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='translate-y-[-0.25px] p-[1px]'
onClick={handleImportWorkflow}
disabled={!canEdit}
>
<Download className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>Import workflows</p>
</Tooltip.Content>
</Tooltip.Root>
)}
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
@@ -622,8 +635,7 @@ export function Sidebar() {
<WorkflowList
regularWorkflows={regularWorkflows}
isLoading={isLoading}
isImporting={isImporting}
setIsImporting={setIsImporting}
handleFileChange={handleImportFileChange}
fileInputRef={fileInputRef}
scrollContainerRef={scrollContainerRef}
/>

View File

@@ -4,6 +4,7 @@ export { useDeleteWorkflow } from './use-delete-workflow'
export { useDuplicateFolder } from './use-duplicate-folder'
export { useDuplicateWorkflow } from './use-duplicate-workflow'
export { useDuplicateWorkspace } from './use-duplicate-workspace'
export { useExportFolder } from './use-export-folder'
export { useExportWorkflow } from './use-export-workflow'
export { useExportWorkspace } from './use-export-workspace'
export { useImportWorkflow } from './use-import-workflow'

View File

@@ -0,0 +1,251 @@
import { useCallback, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import JSZip from 'jszip'
import { sanitizeForExport } from '@/lib/workflows/sanitization/json-sanitizer'
import { useFolderStore } from '@/stores/folders/store'
import type { WorkflowFolder } from '@/stores/folders/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
import type { Variable } from '@/stores/workflows/workflow/types'
const logger = createLogger('useExportFolder')
interface UseExportFolderProps {
/**
* Current workspace ID
*/
workspaceId: string
/**
* Function that returns the folder ID to export
* This function is called when export occurs to get fresh state
*/
getFolderId: () => string
/**
* Optional callback after successful export
*/
onSuccess?: () => void
}
/**
* Recursively collects all workflow IDs within a folder and its subfolders.
*
* @param folderId - The folder ID to collect workflows from
* @param workflows - All workflows in the workspace
* @param folders - All folders in the workspace
* @returns Array of workflow IDs
*/
function collectWorkflowsInFolder(
folderId: string,
workflows: Record<string, WorkflowMetadata>,
folders: Record<string, WorkflowFolder>
): string[] {
const workflowIds: string[] = []
// Get workflows directly in this folder
for (const workflow of Object.values(workflows)) {
if (workflow.folderId === folderId) {
workflowIds.push(workflow.id)
}
}
// Recursively get workflows from child folders
for (const folder of Object.values(folders)) {
if (folder.parentId === folderId) {
const childWorkflowIds = collectWorkflowsInFolder(folder.id, workflows, folders)
workflowIds.push(...childWorkflowIds)
}
}
return workflowIds
}
/**
* Hook for managing folder export to ZIP.
*
* Handles:
* - Collecting all workflows within a folder (including nested subfolders)
* - Fetching workflow data and variables from API
* - Sanitizing workflow state for export
* - Downloading as ZIP file
* - Loading state management
* - Error handling and logging
* - Clearing selection after export
*
* @param props - Hook configuration
* @returns Export folder handlers and state
*/
export function useExportFolder({ workspaceId, getFolderId, onSuccess }: UseExportFolderProps) {
const { workflows } = useWorkflowRegistry()
const { folders } = useFolderStore()
const [isExporting, setIsExporting] = useState(false)
/**
* Check if the folder has any workflows (recursively)
*/
const hasWorkflows = useMemo(() => {
const folderId = getFolderId()
if (!folderId) return false
return collectWorkflowsInFolder(folderId, workflows, folders).length > 0
}, [getFolderId, workflows, folders])
/**
* Download file helper
*/
const downloadFile = (content: Blob, filename: string, mimeType = 'application/zip') => {
try {
const blob = content instanceof Blob ? content : new Blob([content], { type: mimeType })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch (error) {
logger.error('Failed to download file:', error)
}
}
/**
* Export all workflows in the folder (including nested subfolders) to ZIP
*/
const handleExportFolder = useCallback(async () => {
if (isExporting) {
return
}
setIsExporting(true)
try {
// Get fresh folder ID at export time
const folderId = getFolderId()
if (!folderId) {
logger.warn('No folder ID provided for export')
return
}
const folderStore = useFolderStore.getState()
const folder = folderStore.getFolderById(folderId)
if (!folder) {
logger.warn('Folder not found for export', { folderId })
return
}
// Collect all workflow IDs recursively
const workflowIdsToExport = collectWorkflowsInFolder(folderId, workflows, folderStore.folders)
if (workflowIdsToExport.length === 0) {
logger.warn('No workflows found in folder to export', { folderId, folderName: folder.name })
return
}
logger.info('Starting folder export', {
folderId,
folderName: folder.name,
workflowCount: workflowIdsToExport.length,
})
const exportedWorkflows: Array<{ name: string; content: string }> = []
// Export each workflow
for (const workflowId of workflowIdsToExport) {
try {
const workflow = workflows[workflowId]
if (!workflow) {
logger.warn(`Workflow ${workflowId} not found in registry`)
continue
}
// Fetch workflow state from API
const workflowResponse = await fetch(`/api/workflows/${workflowId}`)
if (!workflowResponse.ok) {
logger.error(`Failed to fetch workflow ${workflowId}`)
continue
}
const { data: workflowData } = await workflowResponse.json()
if (!workflowData?.state) {
logger.warn(`Workflow ${workflowId} has no state`)
continue
}
// Fetch workflow variables (API returns Record format directly)
const variablesResponse = await fetch(`/api/workflows/${workflowId}/variables`)
let workflowVariables: Record<string, Variable> | undefined
if (variablesResponse.ok) {
const variablesData = await variablesResponse.json()
workflowVariables = variablesData?.data
}
// Prepare export state
const workflowState = {
...workflowData.state,
metadata: {
name: workflow.name,
description: workflow.description,
color: workflow.color,
exportedAt: new Date().toISOString(),
},
variables: workflowVariables,
}
const exportState = sanitizeForExport(workflowState)
const jsonString = JSON.stringify(exportState, null, 2)
exportedWorkflows.push({
name: workflow.name,
content: jsonString,
})
logger.info(`Workflow ${workflowId} exported successfully`)
} catch (error) {
logger.error(`Failed to export workflow ${workflowId}:`, error)
}
}
if (exportedWorkflows.length === 0) {
logger.warn('No workflows were successfully exported from folder', {
folderId,
folderName: folder.name,
})
return
}
// Always export as ZIP for folders (even with single workflow)
const zip = new JSZip()
for (const exportedWorkflow of exportedWorkflows) {
const filename = `${exportedWorkflow.name.replace(/[^a-z0-9]/gi, '-')}.json`
zip.file(filename, exportedWorkflow.content)
}
const zipBlob = await zip.generateAsync({ type: 'blob' })
const zipFilename = `${folder.name.replace(/[^a-z0-9]/gi, '-')}-export.zip`
downloadFile(zipBlob, zipFilename, 'application/zip')
// Clear selection after successful export
const { clearSelection } = useFolderStore.getState()
clearSelection()
logger.info('Folder exported successfully', {
folderId,
folderName: folder.name,
workflowCount: exportedWorkflows.length,
})
onSuccess?.()
} catch (error) {
logger.error('Error exporting folder:', { error })
throw error
} finally {
setIsExporting(false)
}
}, [getFolderId, isExporting, workflows, onSuccess])
return {
isExporting,
hasWorkflows,
handleExportFolder,
}
}

View File

@@ -206,9 +206,11 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
logger.info(`Import complete. Imported ${importedWorkflowIds.length} workflow(s)`)
// Navigate to first imported workflow if any
// Navigate to last imported workflow if any
if (importedWorkflowIds.length > 0) {
router.push(`/workspace/${workspaceId}/w/${importedWorkflowIds[0]}`)
router.push(
`/workspace/${workspaceId}/w/${importedWorkflowIds[importedWorkflowIds.length - 1]}`
)
}
} catch (error) {
logger.error('Failed to import workflows:', error)

View File

@@ -0,0 +1,22 @@
/**
* Download icon animation
* Subtle continuous animation for import/download states
* Arrow gently pulses down to suggest downloading motion
*/
@keyframes arrow-pulse {
0%,
100% {
transform: translateY(0);
opacity: 1;
}
50% {
transform: translateY(1.5px);
opacity: 0.7;
}
}
.animated-download-svg {
animation: arrow-pulse 1.5s ease-in-out infinite;
transform-origin: center center;
}

View File

@@ -0,0 +1,42 @@
import type { SVGProps } from 'react'
import styles from '@/components/emcn/icons/animate/download.module.css'
export interface DownloadProps extends SVGProps<SVGSVGElement> {
/**
* Enable animation on the download icon
* @default false
*/
animate?: boolean
}
/**
* Download icon component with optional CSS-based animation
* Based on lucide arrow-down icon structure.
* When animate is false, this is a lightweight static icon with no animation overhead.
* When animate is true, CSS module animations are applied for a subtle pulsing effect.
* @param props - SVG properties including className, animate, etc.
*/
export function Download({ animate = false, className, ...props }: DownloadProps) {
const svgClassName = animate
? `${styles['animated-download-svg']} ${className || ''}`.trim()
: className
return (
<svg
xmlns='http://www.w3.org/2000/svg'
width='24'
height='24'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
className={svgClassName}
{...props}
>
<path d='M12 5v14' />
<path d='m19 12-7 7-7-7' />
</svg>
)
}

View File

@@ -5,6 +5,7 @@ export { ChevronDown } from './chevron-down'
export { Connections } from './connections'
export { Copy } from './copy'
export { DocumentAttachment } from './document-attachment'
export { Download } from './download'
export { Duplicate } from './duplicate'
export { Eye } from './eye'
export { FolderCode } from './folder-code'

View File

@@ -97,11 +97,18 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
return acc
}, {})
set((state) => ({
workflows: mapped,
error: null,
hydration:
state.hydration.phase === 'state-loading'
set((state) => {
// Preserve hydration if workflow is loading or already ready and still exists
const shouldPreserveHydration =
state.hydration.phase === 'state-loading' ||
(state.hydration.phase === 'ready' &&
state.hydration.workflowId &&
mapped[state.hydration.workflowId])
return {
workflows: mapped,
error: null,
hydration: shouldPreserveHydration
? state.hydration
: {
phase: 'metadata-ready',
@@ -110,7 +117,8 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
requestId: null,
error: null,
},
}))
}
})
},
failMetadataLoad: (workspaceId: string | null, errorMessage: string) => {