Move upload button

This commit is contained in:
Siddharth Ganesan
2025-07-09 11:37:08 -07:00
parent 763d0de5d5
commit cfc261d646
4 changed files with 360 additions and 345 deletions

View File

@@ -1,340 +0,0 @@
'use client'
import { useRef, useState } from 'react'
import { AlertCircle, CheckCircle, FileText, Plus, Upload } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Textarea } from '@/components/ui/textarea'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { createLogger } from '@/lib/logs/console-logger'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { importWorkflowFromYaml, parseWorkflowYaml } from '@/stores/workflows/yaml/importer'
const logger = createLogger('ImportControls')
interface ImportControlsProps {
disabled?: boolean
}
export function ImportControls({ disabled = false }: ImportControlsProps) {
const [isImporting, setIsImporting] = useState(false)
const [showYamlDialog, setShowYamlDialog] = useState(false)
const [yamlContent, setYamlContent] = useState('')
const [importResult, setImportResult] = useState<{
success: boolean
errors: string[]
warnings: string[]
summary?: string
} | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const router = useRouter()
const params = useParams()
const workspaceId = params.workspaceId as string
// Stores and hooks
const { createWorkflow } = useWorkflowRegistry()
const { collaborativeAddBlock, collaborativeAddEdge, collaborativeSetSubblockValue } =
useCollaborativeWorkflow()
const subBlockStore = useSubBlockStore()
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
try {
const content = await file.text()
setYamlContent(content)
setShowYamlDialog(true)
} catch (error) {
logger.error('Failed to read file:', error)
setImportResult({
success: false,
errors: [
`Failed to read file: ${error instanceof Error ? error.message : 'Unknown error'}`,
],
warnings: [],
})
}
// Reset file input
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
const handleYamlImport = async () => {
if (!yamlContent.trim()) {
setImportResult({
success: false,
errors: ['YAML content is required'],
warnings: [],
})
return
}
setIsImporting(true)
setImportResult(null)
try {
// First validate the YAML without importing
const { data: yamlWorkflow, errors: parseErrors } = parseWorkflowYaml(yamlContent)
if (!yamlWorkflow || parseErrors.length > 0) {
setImportResult({
success: false,
errors: parseErrors,
warnings: [],
})
return
}
// Create a new workflow
const newWorkflowId = await createWorkflow({
name: `Imported Workflow - ${new Date().toLocaleString()}`,
description: 'Workflow imported from YAML',
workspaceId,
})
// Import the YAML into the new workflow BEFORE navigation (creates complete state and saves directly to DB)
// This avoids timing issues with workflow reload during navigation
const result = await importWorkflowFromYaml(
yamlContent,
{
addBlock: collaborativeAddBlock,
addEdge: collaborativeAddEdge,
applyAutoLayout: () => {
// Trigger auto layout
window.dispatchEvent(new CustomEvent('trigger-auto-layout'))
},
setSubBlockValue: (blockId: string, subBlockId: string, value: any) => {
// Use the collaborative function - the same one called when users type into fields
collaborativeSetSubblockValue(blockId, subBlockId, value)
},
getExistingBlocks: () => {
// For a new workflow, we'll get the starter block from the server
return {}
},
},
newWorkflowId
) // Pass the new workflow ID to import into
// Navigate to the new workflow AFTER import is complete
if (result.success) {
logger.info('Navigating to imported workflow')
router.push(`/workspace/${workspaceId}/w/${newWorkflowId}`)
}
setImportResult(result)
if (result.success) {
setYamlContent('')
setShowYamlDialog(false)
logger.info('YAML import completed successfully')
}
} catch (error) {
logger.error('Failed to import YAML workflow:', error)
setImportResult({
success: false,
errors: [`Import failed: ${error instanceof Error ? error.message : 'Unknown error'}`],
warnings: [],
})
} finally {
setIsImporting(false)
}
}
const handleOpenYamlDialog = () => {
setYamlContent('')
setImportResult(null)
setShowYamlDialog(true)
}
const isDisabled = disabled || isImporting
return (
<>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenu>
<DropdownMenuTrigger asChild>
{isDisabled ? (
<div className='inline-flex h-10 w-10 cursor-not-allowed items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium text-sm opacity-50 ring-offset-background transition-colors [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0'>
<Plus className='h-4 w-4' />
</div>
) : (
<Button
variant='ghost'
size='icon'
className='hover:text-primary'
disabled={isDisabled}
>
<Plus className='h-4 w-4' />
<span className='sr-only'>Import Workflow</span>
</Button>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-64'>
<DropdownMenuItem
onClick={() => fileInputRef.current?.click()}
disabled={isDisabled}
className='flex cursor-pointer items-center gap-2'
>
<Upload className='h-4 w-4' />
<div className='flex flex-col'>
<span>Upload YAML File</span>
<span className='text-muted-foreground text-xs'>
Import from .yaml or .yml file
</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={handleOpenYamlDialog}
disabled={isDisabled}
className='flex cursor-pointer items-center gap-2'
>
<FileText className='h-4 w-4' />
<div className='flex flex-col'>
<span>Paste YAML</span>
<span className='text-muted-foreground text-xs'>Import from YAML text</span>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TooltipTrigger>
<TooltipContent>
{isDisabled
? isImporting
? 'Importing workflow...'
: 'Cannot import workflow'
: 'Import Workflow from YAML'}
</TooltipContent>
</Tooltip>
{/* Hidden file input */}
<input
ref={fileInputRef}
type='file'
accept='.yaml,.yml'
onChange={handleFileUpload}
className='hidden'
/>
{/* YAML Import Dialog */}
<Dialog open={showYamlDialog} onOpenChange={setShowYamlDialog}>
<DialogContent className='flex max-h-[80vh] max-w-4xl flex-col'>
<DialogHeader>
<DialogTitle>Import Workflow from YAML</DialogTitle>
<DialogDescription>
Paste your workflow YAML content below. This will create a new workflow with the
blocks and connections defined in the YAML.
</DialogDescription>
</DialogHeader>
<div className='flex-1 space-y-4 overflow-hidden'>
<Textarea
placeholder={`version: "1.0"
blocks:
start:
type: "starter"
name: "Start"
inputs:
startWorkflow: "manual"
following:
- "process"
process:
type: "agent"
name: "Process Data"
inputs:
systemPrompt: "You are a helpful assistant"
userPrompt: "Process the data"
model: "gpt-4"
preceding:
- "start"`}
value={yamlContent}
onChange={(e) => setYamlContent(e.target.value)}
className='min-h-[300px] font-mono text-sm'
disabled={isImporting}
/>
{/* Import Result */}
{importResult && (
<div className='space-y-2'>
{importResult.success ? (
<Alert>
<CheckCircle className='h-4 w-4' />
<AlertDescription>
<div className='font-medium text-green-700'>Import Successful!</div>
{importResult.summary && (
<div className='mt-1 text-sm'>{importResult.summary}</div>
)}
{importResult.warnings.length > 0 && (
<div className='mt-2'>
<div className='font-medium text-sm'>Warnings:</div>
<ul className='mt-1 space-y-1 text-sm'>
{importResult.warnings.map((warning, index) => (
<li key={index} className='text-yellow-700'>
{warning}
</li>
))}
</ul>
</div>
)}
</AlertDescription>
</Alert>
) : (
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
<div className='font-medium'>Import Failed</div>
{importResult.errors.length > 0 && (
<ul className='mt-2 space-y-1 text-sm'>
{importResult.errors.map((error, index) => (
<li key={index}> {error}</li>
))}
</ul>
)}
</AlertDescription>
</Alert>
)}
</div>
)}
</div>
<DialogFooter>
<Button
variant='outline'
onClick={() => setShowYamlDialog(false)}
disabled={isImporting}
>
Cancel
</Button>
<Button onClick={handleYamlImport} disabled={isImporting || !yamlContent.trim()}>
{isImporting ? 'Importing...' : 'Import Workflow'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -58,7 +58,7 @@ import { useWorkflowExecution } from '../../hooks/use-workflow-execution'
import { DeploymentControls } from './components/deployment-controls/deployment-controls'
import { ExportControls } from './components/export-controls/export-controls'
import { HistoryDropdownItem } from './components/history-dropdown-item/history-dropdown-item'
import { ImportControls } from './components/import-controls/import-controls'
import { MarketplaceModal } from './components/marketplace-modal/marketplace-modal'
import { NotificationDropdownItem } from './components/notification-dropdown-item/notification-dropdown-item'
import { UserAvatarStack } from './components/user-avatar-stack/user-avatar-stack'
@@ -1289,7 +1289,7 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
{renderDuplicateButton()}
{renderAutoLayoutButton()}
{renderDebugModeToggle()}
<ImportControls disabled={!userPermissions.canEdit} />
<ExportControls disabled={!userPermissions.canRead} />
{/* <WorkflowTextEditorModal disabled={!userPermissions.canEdit} /> */}
{/* {renderPublishButton()} */}

View File

@@ -1,16 +1,19 @@
'use client'
import { useState } from 'react'
import { useState, useRef } from 'react'
import { logger } from '@sentry/nextjs'
import { File, Folder, Plus } from 'lucide-react'
import { ChevronRight, File, Folder, Plus, Upload } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Separator } from '@/components/ui/separator'
import { cn } from '@/lib/utils'
import { useFolderStore } from '@/stores/folders/store'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/w/components/providers/workspace-permissions-provider'
import { ImportControls, ImportControlsRef } from './import-controls'
interface CreateMenuProps {
onCreateWorkflow: (folderId?: string) => void
@@ -27,10 +30,15 @@ export function CreateMenu({
const [folderName, setFolderName] = useState('')
const [isCreating, setIsCreating] = useState(false)
const [isHoverOpen, setIsHoverOpen] = useState(false)
const [isImportSubmenuOpen, setIsImportSubmenuOpen] = useState(false)
const params = useParams()
const workspaceId = params.workspaceId as string
const { createFolder } = useFolderStore()
const userPermissions = useUserPermissionsContext()
// Ref for the file input that will be used by ImportControls
const importControlsRef = useRef<ImportControlsRef>(null)
const handleCreateWorkflow = () => {
setIsHoverOpen(false)
@@ -42,6 +50,13 @@ export function CreateMenu({
setShowFolderDialog(true)
}
const handleUploadYaml = () => {
setIsHoverOpen(false)
setIsImportSubmenuOpen(false)
// Trigger the file upload from ImportControls component
importControlsRef.current?.triggerFileUpload()
}
const handleFolderSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!folderName.trim() || !workspaceId) return
@@ -99,7 +114,7 @@ export function CreateMenu({
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
'z-50 animate-in overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md',
'data-[state=closed]:animate-out',
'w-40'
'w-48'
)}
onMouseEnter={() => setIsHoverOpen(true)}
onMouseLeave={() => setIsHoverOpen(false)}
@@ -119,6 +134,7 @@ export function CreateMenu({
<File className='h-4 w-4' />
{isCreatingWorkflow ? 'Creating...' : 'New Workflow'}
</button>
<button
className='flex w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground'
onClick={handleCreateFolder}
@@ -126,9 +142,63 @@ export function CreateMenu({
<Folder className='h-4 w-4' />
New Folder
</button>
{userPermissions.canEdit && (
<>
<Separator className='my-1' />
<Popover open={isImportSubmenuOpen} onOpenChange={setIsImportSubmenuOpen}>
<PopoverTrigger asChild>
<button
className='flex w-full cursor-default select-none items-center justify-between rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground'
onMouseEnter={() => setIsImportSubmenuOpen(true)}
>
<div className='flex items-center gap-2'>
<Upload className='h-4 w-4' />
<span>Import Workflow</span>
</div>
<ChevronRight className='h-3 w-3' />
</button>
</PopoverTrigger>
<PopoverContent
side='right'
align='start'
sideOffset={4}
className='w-48 p-1'
onMouseEnter={() => setIsImportSubmenuOpen(true)}
onMouseLeave={() => setIsImportSubmenuOpen(false)}
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<button
className='flex w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground'
onClick={handleUploadYaml}
>
<Upload className='h-4 w-4' />
<div className='flex flex-col items-start'>
<span>YAML</span>
<span className='text-muted-foreground text-xs'>
.yaml or .yml
</span>
</div>
</button>
</PopoverContent>
</Popover>
</>
)}
</PopoverContent>
</Popover>
{/* Import Controls Component - handles all import functionality */}
<ImportControls
ref={importControlsRef}
disabled={!userPermissions.canEdit}
onClose={() => {
setIsHoverOpen(false)
setIsImportSubmenuOpen(false)
}}
/>
{/* Folder creation dialog */}
<Dialog open={showFolderDialog} onOpenChange={setShowFolderDialog}>
<DialogContent className='sm:max-w-[425px]'>

View File

@@ -0,0 +1,285 @@
'use client'
import { forwardRef, useImperativeHandle, useRef, useState } from 'react'
import { AlertCircle, CheckCircle, FileText, Upload } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Textarea } from '@/components/ui/textarea'
import { createLogger } from '@/lib/logs/console-logger'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { importWorkflowFromYaml, parseWorkflowYaml } from '@/stores/workflows/yaml/importer'
const logger = createLogger('ImportControls')
interface ImportControlsProps {
disabled?: boolean
onClose?: () => void
}
export interface ImportControlsRef {
triggerFileUpload: () => void
}
export const ImportControls = forwardRef<ImportControlsRef, ImportControlsProps>(
({ disabled = false, onClose }, ref) => {
const [isImporting, setIsImporting] = useState(false)
const [showYamlDialog, setShowYamlDialog] = useState(false)
const [yamlContent, setYamlContent] = useState('')
const [importResult, setImportResult] = useState<{
success: boolean
errors: string[]
warnings: string[]
summary?: string
} | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const router = useRouter()
const params = useParams()
const workspaceId = params.workspaceId as string
// Stores and hooks
const { createWorkflow } = useWorkflowRegistry()
const { collaborativeAddBlock, collaborativeAddEdge, collaborativeSetSubblockValue } =
useCollaborativeWorkflow()
const subBlockStore = useSubBlockStore()
// Expose methods to parent component
useImperativeHandle(ref, () => ({
triggerFileUpload: () => {
fileInputRef.current?.click()
},
}))
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
try {
const content = await file.text()
setYamlContent(content)
setShowYamlDialog(true)
onClose?.()
} catch (error) {
logger.error('Failed to read file:', error)
setImportResult({
success: false,
errors: [
`Failed to read file: ${error instanceof Error ? error.message : 'Unknown error'}`,
],
warnings: [],
})
}
// Reset file input
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
const handleYamlImport = async () => {
if (!yamlContent.trim()) {
setImportResult({
success: false,
errors: ['YAML content is required'],
warnings: [],
})
return
}
setIsImporting(true)
setImportResult(null)
try {
// First validate the YAML without importing
const { data: yamlWorkflow, errors: parseErrors } = parseWorkflowYaml(yamlContent)
if (!yamlWorkflow || parseErrors.length > 0) {
setImportResult({
success: false,
errors: parseErrors,
warnings: [],
})
return
}
// Create a new workflow
const newWorkflowId = await createWorkflow({
name: `Imported Workflow - ${new Date().toLocaleString()}`,
description: 'Workflow imported from YAML',
workspaceId,
})
// Import the YAML into the new workflow BEFORE navigation (creates complete state and saves directly to DB)
// This avoids timing issues with workflow reload during navigation
const result = await importWorkflowFromYaml(
yamlContent,
{
addBlock: collaborativeAddBlock,
addEdge: collaborativeAddEdge,
applyAutoLayout: () => {
// Trigger auto layout
window.dispatchEvent(new CustomEvent('trigger-auto-layout'))
},
setSubBlockValue: (blockId: string, subBlockId: string, value: any) => {
// Use the collaborative function - the same one called when users type into fields
collaborativeSetSubblockValue(blockId, subBlockId, value)
},
getExistingBlocks: () => {
// For a new workflow, we'll get the starter block from the server
return {}
},
},
newWorkflowId
) // Pass the new workflow ID to import into
// Navigate to the new workflow AFTER import is complete
if (result.success) {
logger.info('Navigating to imported workflow')
router.push(`/workspace/${workspaceId}/w/${newWorkflowId}`)
}
setImportResult(result)
if (result.success) {
setYamlContent('')
setShowYamlDialog(false)
logger.info('YAML import completed successfully')
}
} catch (error) {
logger.error('Failed to import YAML workflow:', error)
setImportResult({
success: false,
errors: [`Import failed: ${error instanceof Error ? error.message : 'Unknown error'}`],
warnings: [],
})
} finally {
setIsImporting(false)
}
}
const isDisabled = disabled || isImporting
return (
<>
{/* Hidden file input */}
<input
ref={fileInputRef}
type='file'
accept='.yaml,.yml'
onChange={handleFileUpload}
className='hidden'
/>
{/* YAML Import Dialog */}
<Dialog open={showYamlDialog} onOpenChange={setShowYamlDialog}>
<DialogContent className='flex max-h-[80vh] max-w-4xl flex-col'>
<DialogHeader>
<DialogTitle>Import Workflow from YAML</DialogTitle>
<DialogDescription>
Review the YAML content below and click "Import Workflow" to create a new workflow with the
blocks and connections defined in the YAML.
</DialogDescription>
</DialogHeader>
<div className='flex-1 space-y-4 overflow-hidden'>
<Textarea
placeholder={`version: "1.0"
blocks:
start:
type: "starter"
name: "Start"
inputs:
startWorkflow: "manual"
following:
- "process"
process:
type: "agent"
name: "Process Data"
inputs:
systemPrompt: "You are a helpful assistant"
userPrompt: "Process the data"
model: "gpt-4"
preceding:
- "start"`}
value={yamlContent}
onChange={(e) => setYamlContent(e.target.value)}
className='min-h-[300px] font-mono text-sm'
disabled={isImporting}
/>
{/* Import Result */}
{importResult && (
<div className='space-y-2'>
{importResult.success ? (
<Alert>
<CheckCircle className='h-4 w-4' />
<AlertDescription>
<div className='font-medium text-green-700'>Import Successful!</div>
{importResult.summary && (
<div className='mt-1 text-sm'>{importResult.summary}</div>
)}
{importResult.warnings.length > 0 && (
<div className='mt-2'>
<div className='font-medium text-sm'>Warnings:</div>
<ul className='mt-1 space-y-1 text-sm'>
{importResult.warnings.map((warning, index) => (
<li key={index} className='text-yellow-700'>
{warning}
</li>
))}
</ul>
</div>
)}
</AlertDescription>
</Alert>
) : (
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
<div className='font-medium'>Import Failed</div>
{importResult.errors.length > 0 && (
<ul className='mt-2 space-y-1 text-sm'>
{importResult.errors.map((error, index) => (
<li key={index}> {error}</li>
))}
</ul>
)}
</AlertDescription>
</Alert>
)}
</div>
)}
</div>
<DialogFooter>
<Button
variant='outline'
onClick={() => setShowYamlDialog(false)}
disabled={isImporting}
>
Cancel
</Button>
<Button onClick={handleYamlImport} disabled={isImporting || !yamlContent.trim()}>
{isImporting ? 'Importing...' : 'Import Workflow'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
)
ImportControls.displayName = 'ImportControls'