fix(workflow-preview): fixed the workflow preview to pull directly from the state in DB

This commit is contained in:
Adam Gough
2025-06-02 14:56:38 -07:00
committed by GitHub
48 changed files with 2477 additions and 1720 deletions

View File

@@ -1,16 +1,22 @@
import { eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import type { NextRequest, NextResponse } from 'next/server'
import { createLogger } from '@/lib/logs/console-logger'
import { db } from '@/db'
import { workflow } from '@/db/schema'
import { validateWorkflowAccess } from '../../middleware'
import { createErrorResponse, createSuccessResponse } from '../../utils'
const logger = createLogger('WorkflowDeployedAPI')
const logger = createLogger('WorkflowDeployedStateAPI')
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
// Helper function to add Cache-Control headers to NextResponse
function addNoCacheHeaders(response: NextResponse): NextResponse {
response.headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
return response
}
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = crypto.randomUUID().slice(0, 8)
const { id } = await params
@@ -21,10 +27,11 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
if (validation.error) {
logger.warn(`[${requestId}] Failed to fetch deployed state: ${validation.error.message}`)
return createErrorResponse(validation.error.message, validation.error.status)
const response = createErrorResponse(validation.error.message, validation.error.status)
return addNoCacheHeaders(response)
}
// Fetch just the deployed state
// Fetch the workflow's deployed state
const result = await db
.select({
deployedState: workflow.deployedState,
@@ -36,27 +43,28 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
if (result.length === 0) {
logger.warn(`[${requestId}] Workflow not found: ${id}`)
return createErrorResponse('Workflow not found', 404)
const response = createErrorResponse('Workflow not found', 404)
return addNoCacheHeaders(response)
}
const workflowData = result[0]
// If the workflow is not deployed, return appropriate response
if (!workflowData.isDeployed || !workflowData.deployedState) {
logger.info(`[${requestId}] No deployed state available for workflow: ${id}`)
return createSuccessResponse({
const response = createSuccessResponse({
deployedState: null,
isDeployed: false,
message: 'Workflow is not deployed or has no deployed state',
})
return addNoCacheHeaders(response)
}
logger.info(`[${requestId}] Successfully retrieved DEPLOYED state: ${id}`)
return createSuccessResponse({
const response = createSuccessResponse({
deployedState: workflowData.deployedState,
isDeployed: true,
})
return addNoCacheHeaders(response)
} catch (error: any) {
logger.error(`[${requestId}] Error fetching deployed state: ${id}`, error)
return createErrorResponse(error.message || 'Failed to fetch deployed state', 500)
const response = createErrorResponse(error.message || 'Failed to fetch deployed state', 500)
return addNoCacheHeaders(response)
}
}

View File

@@ -20,12 +20,12 @@ import { ApiKey } from '@/app/w/[id]/components/control-bar/components/deploy-mo
import { DeployStatus } from '@/app/w/[id]/components/control-bar/components/deploy-modal/components/deployment-info/components/deploy-status/deploy-status'
import { ExampleCommand } from '@/app/w/[id]/components/control-bar/components/deploy-modal/components/deployment-info/components/example-command/example-command'
import { useNotificationStore } from '@/stores/notifications/store'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import { DeployedWorkflowModal } from '../../../deployment-controls/components/deployed-workflow-modal'
interface DeploymentInfoProps {
isLoading: boolean
isLoading?: boolean
deploymentInfo: {
isDeployed: boolean
deployedAt?: string
apiKey: string
endpoint: string
@@ -36,7 +36,9 @@ interface DeploymentInfoProps {
onUndeploy: () => void
isSubmitting: boolean
isUndeploying: boolean
workflowId?: string
workflowId: string | null
deployedState: WorkflowState
isLoadingDeployedState: boolean
}
export function DeploymentInfo({
@@ -47,9 +49,10 @@ export function DeploymentInfo({
isSubmitting,
isUndeploying,
workflowId,
deployedState,
isLoadingDeployedState,
}: DeploymentInfoProps) {
const [isViewingDeployed, setIsViewingDeployed] = useState(false)
const [deployedWorkflowState, setDeployedWorkflowState] = useState<any>(null)
const { addNotification } = useNotificationStore()
const handleViewDeployed = async () => {
@@ -58,28 +61,13 @@ export function DeploymentInfo({
return
}
try {
const response = await fetch(`/api/workflows/${workflowId}/deployed`)
if (!response.ok) {
throw new Error('Failed to fetch deployed workflow')
}
const data = await response.json()
if (data?.deployedState) {
setDeployedWorkflowState(data.deployedState)
setIsViewingDeployed(true)
} else {
addNotification('error', 'Failed to view deployment: No deployment state found', workflowId)
}
} catch (error) {
console.error('Error fetching deployed workflow:', error)
addNotification(
'error',
`Failed to fetch deployed workflow: ${(error as Error).message}`,
workflowId
)
// If deployedState is already loaded, use it directly
if (deployedState) {
setIsViewingDeployed(true)
return
}
if (!isLoadingDeployedState) {
addNotification('error', 'Cannot view deployment: No deployed state available', workflowId)
}
}
@@ -168,11 +156,12 @@ export function DeploymentInfo({
</div>
</div>
{deployedWorkflowState && (
{deployedState && (
<DeployedWorkflowModal
isOpen={isViewingDeployed}
onClose={() => setIsViewingDeployed(false)}
deployedWorkflowState={deployedWorkflowState}
needsRedeployment={deploymentInfo.needsRedeployment}
deployedWorkflowState={deployedState}
/>
)}
</>

View File

@@ -27,6 +27,7 @@ import { useNotificationStore } from '@/stores/notifications/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('DeployModal')
@@ -36,6 +37,9 @@ interface DeployModalProps {
workflowId: string | null
needsRedeployment: boolean
setNeedsRedeployment: (value: boolean) => void
deployedState: WorkflowState
isLoadingDeployedState: boolean
refetchDeployedState: () => Promise<void>
}
interface ApiKey {
@@ -69,6 +73,9 @@ export function DeployModal({
workflowId,
needsRedeployment,
setNeedsRedeployment,
deployedState,
isLoadingDeployedState,
refetchDeployedState,
}: DeployModalProps) {
// Store hooks
const { addNotification } = useNotificationStore()
@@ -306,6 +313,9 @@ export function DeployModal({
setDeploymentInfo(newDeploymentInfo)
// Fetch the updated deployed state after deployment
await refetchDeployedState()
// No notification on successful deploy
} catch (error: any) {
logger.error('Error deploying workflow:', { error })
@@ -395,7 +405,9 @@ export function DeployModal({
useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(workflowId, false)
}
// Add a success notification
// Fetch the updated deployed state after redeployment
await refetchDeployedState()
addNotification('info', 'Workflow successfully redeployed', workflowId)
} catch (error: any) {
logger.error('Error redeploying workflow:', { error })
@@ -407,7 +419,6 @@ export function DeployModal({
// Custom close handler to ensure we clean up loading states
const handleCloseModal = () => {
// Reset all loading states
setIsSubmitting(false)
setIsChatDeploying(false)
setChatSubmitting(false)
@@ -505,8 +516,6 @@ export function DeployModal({
deployedAt ? new Date(deployedAt) : undefined,
apiKey
)
logger.info('Workflow automatically deployed for chat deployment')
} catch (error: any) {
logger.error('Error auto-deploying workflow for chat:', { error })
addNotification('error', `Failed to deploy workflow: ${error.message}`, workflowId)
@@ -606,38 +615,43 @@ export function DeployModal({
<div className='flex-1 overflow-y-auto'>
<div className='p-6'>
{activeTab === 'api' &&
(isDeployed ? (
<DeploymentInfo
isLoading={isLoading}
deploymentInfo={deploymentInfo}
onRedeploy={handleRedeploy}
onUndeploy={handleUndeploy}
isSubmitting={isSubmitting}
isUndeploying={isUndeploying}
workflowId={workflowId || undefined}
/>
) : (
<>
{apiDeployError && (
<div className='mb-4 rounded-md border border-destructive/30 bg-destructive/10 p-3 text-destructive text-sm'>
<div className='font-semibold'>API Deployment Error</div>
<div>{apiDeployError}</div>
{activeTab === 'api' && (
<>
{isDeployed ? (
<DeploymentInfo
isLoading={isLoading}
deploymentInfo={deploymentInfo}
onRedeploy={handleRedeploy}
onUndeploy={handleUndeploy}
isSubmitting={isSubmitting}
isUndeploying={isUndeploying}
workflowId={workflowId}
deployedState={deployedState}
isLoadingDeployedState={isLoadingDeployedState}
/>
) : (
<>
{apiDeployError && (
<div className='mb-4 rounded-md border border-destructive/30 bg-destructive/10 p-3 text-destructive text-sm'>
<div className='font-semibold'>API Deployment Error</div>
<div>{apiDeployError}</div>
</div>
)}
<div className='-mx-1 px-1'>
<DeployForm
apiKeys={apiKeys}
keysLoaded={keysLoaded}
endpointUrl={`${env.NEXT_PUBLIC_APP_URL}/api/workflows/${workflowId}/execute`}
workflowId={workflowId || ''}
onSubmit={onDeploy}
getInputFormatExample={getInputFormatExample}
onApiKeyCreated={fetchApiKeys}
/>
</div>
)}
<div className='-mx-1 px-1'>
<DeployForm
apiKeys={apiKeys}
keysLoaded={keysLoaded}
endpointUrl={`${env.NEXT_PUBLIC_APP_URL}/api/workflows/${workflowId}/execute`}
workflowId={workflowId || ''}
onSubmit={onDeploy}
getInputFormatExample={getInputFormatExample}
onApiKeyCreated={fetchApiKeys}
/>
</div>
</>
))}
</>
)}
</>
)}
{activeTab === 'chat' && (
<ChatDeploy

View File

@@ -1,22 +1,20 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { useMemo, useState } from 'react'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { createLogger } from '@/lib/logs/console-logger'
import { cn } from '@/lib/utils'
import { WorkflowPreview } from '@/app/w/components/workflow-preview/generic-workflow-preview'
import { WorkflowPreview } from '@/app/w/components/workflow-preview/workflow-preview'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('DeployedWorkflowCard')
interface DeployedWorkflowCardProps {
currentWorkflowState?: {
blocks: Record<string, any>
edges: Array<any>
loops: Record<string, any>
}
deployedWorkflowState: {
blocks: Record<string, any>
edges: Array<any>
loops: Record<string, any>
}
currentWorkflowState?: WorkflowState
deployedWorkflowState: WorkflowState
className?: string
}
@@ -27,13 +25,17 @@ export function DeployedWorkflowCard({
}: DeployedWorkflowCardProps) {
const [showingDeployed, setShowingDeployed] = useState(true)
const workflowToShow = showingDeployed ? deployedWorkflowState : currentWorkflowState
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
const previewKey = useMemo(() => {
return `${showingDeployed ? 'deployed' : 'current'}-preview-${activeWorkflowId}}`
}, [showingDeployed, activeWorkflowId])
return (
<Card className={cn('relative overflow-hidden', className)}>
<CardHeader
className={cn(
'sticky top-0 z-10 space-y-4 p-4',
'backdrop-blur-xl',
'bg-background/70 dark:bg-background/50',
'border-border/30 border-b dark:border-border/20',
'shadow-sm'
@@ -44,39 +46,42 @@ export function DeployedWorkflowCard({
{showingDeployed ? 'Deployed Workflow' : 'Current Workflow'}
</h3>
{/* Controls */}
<div className='flex items-center gap-4'>
<div className='flex items-center gap-2'>
{/* Version toggle - only show if there's a current version */}
{currentWorkflowState && (
<Button
variant='outline'
size='sm'
onClick={() => setShowingDeployed(!showingDeployed)}
>
{showingDeployed ? 'Show Current' : 'Show Deployed'}
</Button>
<div className='flex items-center space-x-2'>
<Label htmlFor='workflow-version-toggle' className='text-muted-foreground text-sm'>
Current
</Label>
<Switch
id='workflow-version-toggle'
checked={showingDeployed}
onCheckedChange={setShowingDeployed}
/>
<Label htmlFor='workflow-version-toggle' className='text-muted-foreground text-sm'>
Deployed
</Label>
</div>
)}
</div>
</div>
</CardHeader>
<div className='h-px w-full bg-border shadow-sm' />
<CardContent className='p-0'>
{/* Workflow preview with fixed height */}
<div className='h-[500px] w-full'>
{workflowToShow ? (
<WorkflowPreview
workflowState={workflowToShow}
showSubBlocks={true}
height='100%'
width='100%'
isPannable={true}
defaultPosition={{ x: 0, y: 0 }}
defaultZoom={1}
/>
) : (
<div className='flex h-full items-center justify-center text-muted-foreground'>
No workflow data available
</div>
)}
<WorkflowPreview
key={previewKey}
workflowState={workflowToShow as WorkflowState}
showSubBlocks={true}
height='100%'
width='100%'
isPannable={true}
defaultPosition={{ x: 0, y: 0 }}
defaultZoom={1}
/>
</div>
</CardContent>
</Card>

View File

@@ -14,25 +14,26 @@ import {
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { createLogger } from '@/lib/logs/console-logger'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import { DeployedWorkflowCard } from './deployed-workflow-card'
const logger = createLogger('DeployedWorkflowModal')
interface DeployedWorkflowModalProps {
isOpen: boolean
onClose: () => void
deployedWorkflowState: {
blocks: Record<string, any>
edges: Array<any>
loops: Record<string, any>
parallels: Record<string, any>
}
needsRedeployment: boolean
deployedWorkflowState: WorkflowState
}
export function DeployedWorkflowModal({
isOpen,
onClose,
needsRedeployment,
deployedWorkflowState,
}: DeployedWorkflowModalProps) {
const [showRevertDialog, setShowRevertDialog] = useState(false)
@@ -48,10 +49,11 @@ export function DeployedWorkflowModal({
}))
const handleRevert = () => {
// Revert to the deployed state
revertToDeployedState(deployedWorkflowState)
setShowRevertDialog(false)
onClose()
if (activeWorkflowId) {
revertToDeployedState(deployedWorkflowState)
setShowRevertDialog(false)
onClose()
}
}
return (
@@ -66,38 +68,39 @@ export function DeployedWorkflowModal({
<DialogTitle>Deployed Workflow</DialogTitle>
</DialogHeader>
</div>
<DeployedWorkflowCard
currentWorkflowState={currentWorkflowState}
deployedWorkflowState={deployedWorkflowState}
/>
<div className='mt-6 flex justify-between'>
<AlertDialog open={showRevertDialog} onOpenChange={setShowRevertDialog}>
<AlertDialogTrigger asChild>
<Button variant='destructive'>Revert to Deployed</Button>
</AlertDialogTrigger>
<AlertDialogContent style={{ zIndex: 1001 }} className='sm:max-w-[425px]'>
<AlertDialogHeader>
<AlertDialogTitle>Revert to Deployed Version?</AlertDialogTitle>
<AlertDialogDescription>
This will replace your current workflow with the deployed version. Any unsaved
changes will be lost. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleRevert}
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
>
Revert
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{needsRedeployment && (
<AlertDialog open={showRevertDialog} onOpenChange={setShowRevertDialog}>
<AlertDialogTrigger asChild>
<Button variant='destructive'>Revert to Deployed</Button>
</AlertDialogTrigger>
<AlertDialogContent style={{ zIndex: 1001 }} className='sm:max-w-[425px]'>
<AlertDialogHeader>
<AlertDialogTitle>Revert to Deployed Version?</AlertDialogTitle>
<AlertDialogDescription>
This will replace your current workflow with the deployed version. Any unsaved
changes will be lost. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleRevert}
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
>
Revert
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
<Button variant='outline' onClick={onClose}>
<Button variant='outline' onClick={onClose} className='ml-auto'>
Close
</Button>
</div>

View File

@@ -0,0 +1,211 @@
/**
* Deployment Controls Change Detection Logic Tests
*
* This file tests the core logic of how DeploymentControls handles change detection,
* specifically focusing on the needsRedeployment prop handling and state management.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mockDeploymentStatus = {
isDeployed: false,
needsRedeployment: false,
}
const mockWorkflowRegistry = {
getState: vi.fn(() => ({
getWorkflowDeploymentStatus: vi.fn((workflowId) => mockDeploymentStatus),
})),
}
vi.mock('@/stores/workflows/registry/store', () => ({
useWorkflowRegistry: vi.fn((selector) => {
if (typeof selector === 'function') {
return selector(mockWorkflowRegistry.getState())
}
return mockWorkflowRegistry.getState()
}),
}))
describe('DeploymentControls Change Detection Logic', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDeploymentStatus.isDeployed = false
mockDeploymentStatus.needsRedeployment = false
})
afterEach(() => {
vi.resetAllMocks()
})
describe('needsRedeployment Priority Logic', () => {
it('should prioritize parent needsRedeployment over workflow registry', () => {
const parentNeedsRedeployment = true
const workflowRegistryNeedsRedeployment = false
const workflowNeedsRedeployment = parentNeedsRedeployment
expect(workflowNeedsRedeployment).toBe(true)
expect(workflowNeedsRedeployment).not.toBe(workflowRegistryNeedsRedeployment)
})
it('should handle false needsRedeployment correctly', () => {
const parentNeedsRedeployment = false
const workflowNeedsRedeployment = parentNeedsRedeployment
expect(workflowNeedsRedeployment).toBe(false)
})
it('should maintain consistency with parent state changes', () => {
let parentNeedsRedeployment = false
let workflowNeedsRedeployment = parentNeedsRedeployment
expect(workflowNeedsRedeployment).toBe(false)
parentNeedsRedeployment = true
workflowNeedsRedeployment = parentNeedsRedeployment
expect(workflowNeedsRedeployment).toBe(true)
parentNeedsRedeployment = false
workflowNeedsRedeployment = parentNeedsRedeployment
expect(workflowNeedsRedeployment).toBe(false)
})
})
describe('Deployment Status Integration', () => {
it('should handle deployment status correctly', () => {
mockDeploymentStatus.isDeployed = true
mockDeploymentStatus.needsRedeployment = false
const deploymentStatus = mockWorkflowRegistry
.getState()
.getWorkflowDeploymentStatus('test-id')
expect(deploymentStatus.isDeployed).toBe(true)
expect(deploymentStatus.needsRedeployment).toBe(false)
})
it('should handle missing deployment status', () => {
const tempMockRegistry = {
getState: vi.fn(() => ({
getWorkflowDeploymentStatus: vi.fn(() => null),
})),
}
// Temporarily replace the mock
const originalMock = mockWorkflowRegistry.getState
mockWorkflowRegistry.getState = tempMockRegistry.getState as any
const deploymentStatus = mockWorkflowRegistry
.getState()
.getWorkflowDeploymentStatus('test-id')
expect(deploymentStatus).toBe(null)
mockWorkflowRegistry.getState = originalMock
})
it('should handle undefined deployment status properties', () => {
mockWorkflowRegistry.getState = vi.fn(() => ({
getWorkflowDeploymentStatus: vi.fn(() => ({})),
})) as any
const deploymentStatus = mockWorkflowRegistry
.getState()
.getWorkflowDeploymentStatus('test-id')
const isDeployed = deploymentStatus?.isDeployed || false
expect(isDeployed).toBe(false)
})
})
describe('Change Detection Scenarios', () => {
it('should handle the redeployment cycle correctly', () => {
// Scenario 1: Initial state - deployed, no changes
mockDeploymentStatus.isDeployed = true
let parentNeedsRedeployment = false
let shouldShowIndicator = mockDeploymentStatus.isDeployed && parentNeedsRedeployment
expect(shouldShowIndicator).toBe(false)
// Scenario 2: User makes changes
parentNeedsRedeployment = true
shouldShowIndicator = mockDeploymentStatus.isDeployed && parentNeedsRedeployment
expect(shouldShowIndicator).toBe(true)
// Scenario 3: User redeploys
parentNeedsRedeployment = false // Reset after redeployment
shouldShowIndicator = mockDeploymentStatus.isDeployed && parentNeedsRedeployment
expect(shouldShowIndicator).toBe(false)
})
it('should not show indicator when workflow is not deployed', () => {
mockDeploymentStatus.isDeployed = false
const parentNeedsRedeployment = true
const shouldShowIndicator = mockDeploymentStatus.isDeployed && parentNeedsRedeployment
expect(shouldShowIndicator).toBe(false)
})
it('should show correct tooltip messages based on state', () => {
const getTooltipMessage = (isDeployed: boolean, needsRedeployment: boolean) => {
if (isDeployed && needsRedeployment) {
return 'Workflow changes detected'
}
if (isDeployed) {
return 'Deployment Settings'
}
return 'Deploy as API'
}
// Not deployed
expect(getTooltipMessage(false, false)).toBe('Deploy as API')
expect(getTooltipMessage(false, true)).toBe('Deploy as API')
// Deployed, no changes
expect(getTooltipMessage(true, false)).toBe('Deployment Settings')
// Deployed, changes detected
expect(getTooltipMessage(true, true)).toBe('Workflow changes detected')
})
})
describe('Error Handling', () => {
it('should handle null activeWorkflowId gracefully', () => {
const deploymentStatus = mockWorkflowRegistry.getState().getWorkflowDeploymentStatus(null)
expect(deploymentStatus).toBeDefined()
})
})
describe('Props Integration', () => {
it('should correctly pass needsRedeployment to child components', () => {
const parentNeedsRedeployment = true
const propsToModal = {
needsRedeployment: parentNeedsRedeployment,
workflowId: 'test-id',
}
expect(propsToModal.needsRedeployment).toBe(true)
})
it('should maintain prop consistency across re-renders', () => {
let needsRedeployment = false
let componentProps = { needsRedeployment }
expect(componentProps.needsRedeployment).toBe(false)
needsRedeployment = true
componentProps = { needsRedeployment }
expect(componentProps.needsRedeployment).toBe(true)
needsRedeployment = false
componentProps = { needsRedeployment }
expect(componentProps.needsRedeployment).toBe(false)
})
})
})

View File

@@ -1,56 +1,56 @@
'use client'
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { Loader2, Rocket } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { createLogger } from '@/lib/logs/console-logger'
import { cn } from '@/lib/utils'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import { DeployModal } from '../deploy-modal/deploy-modal'
const _logger = createLogger('DeploymentControls')
interface DeploymentControlsProps {
activeWorkflowId: string | null
needsRedeployment: boolean
setNeedsRedeployment: (value: boolean) => void
deployedState: WorkflowState | null
isLoadingDeployedState: boolean
refetchDeployedState: () => Promise<void>
}
export function DeploymentControls({
activeWorkflowId,
needsRedeployment,
setNeedsRedeployment,
deployedState,
isLoadingDeployedState,
refetchDeployedState,
}: DeploymentControlsProps) {
// Use workflow-specific deployment status
const deploymentStatus = useWorkflowRegistry((state) =>
state.getWorkflowDeploymentStatus(activeWorkflowId)
)
const isDeployed = deploymentStatus?.isDeployed || false
// Prioritize workflow-specific needsRedeployment flag, but fall back to prop if needed
const workflowNeedsRedeployment =
deploymentStatus?.needsRedeployment !== undefined
? deploymentStatus.needsRedeployment
: needsRedeployment
const workflowNeedsRedeployment = needsRedeployment
const [isDeploying, _setIsDeploying] = useState(false)
const [isModalOpen, setIsModalOpen] = useState(false)
// Update parent component when workflow-specific status changes
const lastWorkflowIdRef = useRef<string | null>(null)
useEffect(() => {
if (
deploymentStatus?.needsRedeployment !== undefined &&
deploymentStatus.needsRedeployment !== needsRedeployment
) {
setNeedsRedeployment(deploymentStatus.needsRedeployment)
if (activeWorkflowId !== lastWorkflowIdRef.current) {
lastWorkflowIdRef.current = activeWorkflowId
}
}, [
deploymentStatus?.needsRedeployment,
needsRedeployment,
setNeedsRedeployment,
deploymentStatus,
])
}, [activeWorkflowId])
const refetchWithErrorHandling = async () => {
if (!activeWorkflowId) return
try {
await refetchDeployedState()
} catch (error) {}
}
return (
<>
@@ -100,6 +100,9 @@ export function DeploymentControls({
workflowId={activeWorkflowId}
needsRedeployment={workflowNeedsRedeployment}
setNeedsRedeployment={setNeedsRedeployment}
deployedState={deployedState as WorkflowState}
isLoadingDeployedState={isLoadingDeployedState}
refetchDeployedState={refetchWithErrorHandling}
/>
</>
)

View File

@@ -0,0 +1,355 @@
/**
* @vitest-environment jsdom
*
* Control Bar Change Detection Tests
*
* This file tests the core change detection logic in the ControlBar component,
* specifically focusing on the normalizeBlocksForComparison function and
* semantic comparison of workflow states.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mockWorkflowStore = {
getState: vi.fn(),
subscribe: vi.fn(),
}
const mockSubBlockStore = {
getState: vi.fn(),
subscribe: vi.fn(),
}
const mockWorkflowRegistry = {
getState: vi.fn(),
subscribe: vi.fn(),
}
vi.mock('@/stores/workflows/workflow/store', () => ({
useWorkflowStore: vi.fn((selector) => {
if (typeof selector === 'function') {
return selector(mockWorkflowStore.getState())
}
return mockWorkflowStore
}),
}))
vi.mock('@/stores/workflows/subblock/store', () => ({
useSubBlockStore: vi.fn((selector) => {
if (typeof selector === 'function') {
return selector(mockSubBlockStore.getState())
}
return mockSubBlockStore
}),
}))
vi.mock('@/stores/workflows/registry/store', () => ({
useWorkflowRegistry: vi.fn(() => mockWorkflowRegistry.getState()),
}))
vi.mock('@/stores/workflows/utils', () => ({
mergeSubblockState: vi.fn((blocks) => blocks),
}))
vi.mock('@/lib/logs/console-logger', () => ({
createLogger: () => ({
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
}),
}))
const normalizeBlocksForComparison = (blocks: Record<string, any>) => {
if (!blocks) return []
return Object.values(blocks)
.map((block: any) => ({
type: block.type,
name: block.name,
subBlocks: block.subBlocks || {},
}))
.sort((a, b) => {
// Sort by type first, then by name for consistent comparison
const typeA = a.type || ''
const typeB = b.type || ''
if (typeA !== typeB) return typeA.localeCompare(typeB)
return (a.name || '').localeCompare(b.name || '')
})
}
describe('normalizeBlocksForComparison', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.resetAllMocks()
})
it('should extract only functional properties from blocks', () => {
const blocks = {
'block-1': {
id: 'block-1',
type: 'agent',
name: 'Agent 1',
position: { x: 100, y: 200 },
height: 668,
enabled: true,
subBlocks: {
systemPrompt: { id: 'systemPrompt', type: 'text', value: 'You are helpful' },
},
},
'block-2': {
id: 'block-2',
type: 'api',
name: 'API 1',
position: { x: 300, y: 400 },
height: 400,
enabled: true,
subBlocks: {
url: { id: 'url', type: 'short-input', value: 'https://api.example.com' },
},
},
}
const result = normalizeBlocksForComparison(blocks)
expect(result).toHaveLength(2)
result.forEach((block) => {
expect(block).toHaveProperty('type')
expect(block).toHaveProperty('name')
expect(block).toHaveProperty('subBlocks')
expect(block).not.toHaveProperty('id')
expect(block).not.toHaveProperty('position')
expect(block).not.toHaveProperty('height')
expect(block).not.toHaveProperty('enabled')
})
})
it('should sort blocks consistently by type then name', () => {
const blocks = {
'block-1': { type: 'api', name: 'API 2', subBlocks: {} },
'block-2': { type: 'agent', name: 'Agent 1', subBlocks: {} },
'block-3': { type: 'api', name: 'API 1', subBlocks: {} },
'block-4': { type: 'agent', name: 'Agent 2', subBlocks: {} },
}
const result = normalizeBlocksForComparison(blocks)
expect(result[0]).toEqual({ type: 'agent', name: 'Agent 1', subBlocks: {} })
expect(result[1]).toEqual({ type: 'agent', name: 'Agent 2', subBlocks: {} })
expect(result[2]).toEqual({ type: 'api', name: 'API 1', subBlocks: {} })
expect(result[3]).toEqual({ type: 'api', name: 'API 2', subBlocks: {} })
})
it('should handle blocks with undefined or null properties', () => {
const blocks = {
'block-1': {
type: undefined,
name: null,
subBlocks: {
field1: { value: 'test' },
},
},
'block-2': {
type: 'agent',
name: 'Agent 1',
},
}
const result = normalizeBlocksForComparison(blocks)
expect(result).toHaveLength(2)
expect(result[0]).toEqual({
type: undefined,
name: null,
subBlocks: { field1: { value: 'test' } },
})
expect(result[1]).toEqual({
type: 'agent',
name: 'Agent 1',
subBlocks: {},
})
})
it('should return empty array for null or undefined input', () => {
expect(normalizeBlocksForComparison(null as any)).toEqual([])
expect(normalizeBlocksForComparison(undefined as any)).toEqual([])
expect(normalizeBlocksForComparison({})).toEqual([])
})
it('should preserve subBlock structure completely', () => {
const blocks = {
'agent-block': {
type: 'agent',
name: 'Test Agent',
subBlocks: {
systemPrompt: {
id: 'systemPrompt',
type: 'textarea',
value: 'You are a helpful assistant',
},
model: {
id: 'model',
type: 'dropdown',
value: 'gpt-4',
},
temperature: {
id: 'temperature',
type: 'slider',
value: 0.7,
},
},
},
}
const result = normalizeBlocksForComparison(blocks)
expect(result[0].subBlocks).toEqual(blocks['agent-block'].subBlocks)
})
})
describe('Change Detection Scenarios', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should detect no changes when blocks are functionally identical', () => {
const currentBlocks = {
'new-id-123': {
id: 'new-id-123',
type: 'agent',
name: 'Agent 1',
position: { x: 100, y: 200 },
subBlocks: { systemPrompt: { value: 'Test prompt' } },
},
}
const deployedBlocks = {
'old-id-456': {
id: 'old-id-456',
type: 'agent',
name: 'Agent 1',
position: { x: 300, y: 400 },
subBlocks: { systemPrompt: { value: 'Test prompt' } },
},
}
const normalizedCurrent = normalizeBlocksForComparison(currentBlocks)
const normalizedDeployed = normalizeBlocksForComparison(deployedBlocks)
expect(JSON.stringify(normalizedCurrent)).toBe(JSON.stringify(normalizedDeployed))
})
it('should detect changes when block types differ', () => {
const currentBlocks = {
'block-1': { type: 'agent', name: 'Block 1', subBlocks: {} },
}
const deployedBlocks = {
'block-1': { type: 'api', name: 'Block 1', subBlocks: {} },
}
const normalizedCurrent = normalizeBlocksForComparison(currentBlocks)
const normalizedDeployed = normalizeBlocksForComparison(deployedBlocks)
expect(JSON.stringify(normalizedCurrent)).not.toBe(JSON.stringify(normalizedDeployed))
})
it('should detect changes when block names differ', () => {
const currentBlocks = {
'block-1': { type: 'agent', name: 'Agent Updated', subBlocks: {} },
}
const deployedBlocks = {
'block-1': { type: 'agent', name: 'Agent 1', subBlocks: {} },
}
const normalizedCurrent = normalizeBlocksForComparison(currentBlocks)
const normalizedDeployed = normalizeBlocksForComparison(deployedBlocks)
expect(JSON.stringify(normalizedCurrent)).not.toBe(JSON.stringify(normalizedDeployed))
})
it('should detect changes when subBlock values differ', () => {
const currentBlocks = {
'block-1': {
type: 'agent',
name: 'Agent 1',
subBlocks: {
systemPrompt: { value: 'Updated prompt' },
},
},
}
const deployedBlocks = {
'block-1': {
type: 'agent',
name: 'Agent 1',
subBlocks: {
systemPrompt: { value: 'Original prompt' },
},
},
}
const normalizedCurrent = normalizeBlocksForComparison(currentBlocks)
const normalizedDeployed = normalizeBlocksForComparison(deployedBlocks)
expect(JSON.stringify(normalizedCurrent)).not.toBe(JSON.stringify(normalizedDeployed))
})
it('should detect changes when number of blocks differs', () => {
const currentBlocks = {
'block-1': { type: 'agent', name: 'Agent 1', subBlocks: {} },
'block-2': { type: 'api', name: 'API 1', subBlocks: {} },
}
const deployedBlocks = {
'block-1': { type: 'agent', name: 'Agent 1', subBlocks: {} },
}
const normalizedCurrent = normalizeBlocksForComparison(currentBlocks)
const normalizedDeployed = normalizeBlocksForComparison(deployedBlocks)
expect(normalizedCurrent).toHaveLength(2)
expect(normalizedDeployed).toHaveLength(1)
expect(JSON.stringify(normalizedCurrent)).not.toBe(JSON.stringify(normalizedDeployed))
})
it('should ignore position and metadata changes', () => {
const currentBlocks = {
'block-1': {
id: 'new-id',
type: 'agent',
name: 'Agent 1',
position: { x: 500, y: 600 },
height: 800,
enabled: false,
data: { someMetadata: 'changed' },
subBlocks: { systemPrompt: { value: 'Test' } },
},
}
const deployedBlocks = {
'block-1': {
id: 'old-id',
type: 'agent',
name: 'Agent 1',
position: { x: 100, y: 200 },
height: 600,
enabled: true,
data: { someMetadata: 'original' },
subBlocks: { systemPrompt: { value: 'Test' } },
},
}
const normalizedCurrent = normalizeBlocksForComparison(currentBlocks)
const normalizedDeployed = normalizeBlocksForComparison(deployedBlocks)
// Should be identical since only metadata changed
expect(JSON.stringify(normalizedCurrent)).toBe(JSON.stringify(normalizedDeployed))
})
})

View File

@@ -45,12 +45,14 @@ import { useNotificationStore } from '@/stores/notifications/store'
import { usePanelStore } from '@/stores/panel/store'
import { useGeneralStore } from '@/stores/settings/general/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import {
getKeyboardShortcutText,
useKeyboardShortcuts,
} from '../../../hooks/use-keyboard-shortcuts'
import { useDeploymentChangeDetection } from '../../hooks/use-deployment-change-detection'
import { useWorkflowExecution } from '../../hooks/use-workflow-execution'
import { DeploymentControls } from './components/deployment-controls/deployment-controls'
import { HistoryDropdownItem } from './components/history-dropdown-item/history-dropdown-item'
@@ -86,7 +88,9 @@ export function ControlBar() {
showNotification,
removeNotification,
} = useNotificationStore()
const { history, revertToHistoryState, lastSaved } = useWorkflowStore()
const { history, revertToHistoryState, lastSaved, setNeedsRedeploymentFlag, blocks } =
useWorkflowStore()
const { workflowValues } = useSubBlockStore()
const {
workflows,
updateWorkflow,
@@ -94,6 +98,7 @@ export function ControlBar() {
removeWorkflow,
duplicateWorkflow,
setDeploymentStatus,
isLoading: isRegistryLoading,
} = useWorkflowRegistry()
const { isExecuting, handleRunWorkflow } = useWorkflowExecution()
const { setActiveTab } = usePanelStore()
@@ -107,6 +112,13 @@ export function ControlBar() {
const [mounted, setMounted] = useState(false)
const [, forceUpdate] = useState({})
// Deployed state management
const [deployedState, setDeployedState] = useState<WorkflowState | null>(null)
const [isLoadingDeployedState, setIsLoadingDeployedState] = useState<boolean>(false)
// Change detection state
const [changeDetected, setChangeDetected] = useState(false)
// Workflow name editing state
const [isEditing, setIsEditing] = useState(false)
const [editedName, setEditedName] = useState('')
@@ -150,11 +162,6 @@ export function ControlBar() {
isExecuting || isMultiRunning || isCancelling
)
// Get notifications for current workflow
// const workflowNotifications = activeWorkflowId
// ? getWorkflowNotifications(activeWorkflowId)
// : notifications // Show all if no workflow is active
// Get the marketplace data from the workflow registry if available
const getMarketplaceData = () => {
if (!activeWorkflowId || !workflows[activeWorkflowId]) return null
@@ -179,10 +186,6 @@ export function ControlBar() {
)
const isDeployed = deploymentStatus?.isDeployed || false
// Custom hook for deployment change detection
const { needsRedeployment, setNeedsRedeployment, clearNeedsRedeployment } =
useDeploymentChangeDetection(activeWorkflowId, isDeployed)
// Client-side only rendering for the timestamp
useEffect(() => {
setMounted(true)
@@ -194,27 +197,138 @@ export function ControlBar() {
return () => clearInterval(interval)
}, [])
// Update existing API notifications when needsRedeployment changes
useEffect(() => {
if (!activeWorkflowId) return
/**
* Fetches the deployed state of the workflow from the server
* This is the single source of truth for deployed workflow state
*/
const fetchDeployedState = async () => {
if (!activeWorkflowId || !isDeployed) {
setDeployedState(null)
return
}
const apiNotification = notifications.find(
(n) => n.type === 'api' && n.workflowId === activeWorkflowId && n.options?.isPersistent
)
// Store the workflow ID at the start of the request to prevent race conditions
const requestWorkflowId = activeWorkflowId
if (apiNotification && apiNotification.options?.needsRedeployment !== needsRedeployment) {
// If there's an existing API notification and its state doesn't match, update it
if (apiNotification.isVisible) {
// Only update if it's currently showing to the user
removeNotification(apiNotification.id)
// The DeploymentControls component will handle showing the appropriate notification
// Helper to get current active workflow ID for race condition checks
const getCurrentActiveWorkflowId = () => useWorkflowRegistry.getState().activeWorkflowId
try {
setIsLoadingDeployedState(true)
const response = await fetch(`/api/workflows/${requestWorkflowId}/deployed`)
// Check if the workflow ID changed during the request (user navigated away)
if (requestWorkflowId !== getCurrentActiveWorkflowId()) {
logger.debug('Workflow changed during deployed state fetch, ignoring response')
return
}
if (!response.ok) {
if (response.status === 404) {
setDeployedState(null)
return
}
throw new Error(`Failed to fetch deployed state: ${response.statusText}`)
}
const data = await response.json()
if (requestWorkflowId === getCurrentActiveWorkflowId()) {
setDeployedState(data.deployedState || null)
} else {
logger.debug('Workflow changed after deployed state response, ignoring result')
}
} catch (error) {
logger.error('Error fetching deployed state:', { error })
if (requestWorkflowId === getCurrentActiveWorkflowId()) {
setDeployedState(null)
}
} finally {
if (requestWorkflowId === getCurrentActiveWorkflowId()) {
setIsLoadingDeployedState(false)
}
}
}, [needsRedeployment, activeWorkflowId, notifications, removeNotification, addNotification])
}
// Check usage limits when component mounts and when user executes a workflow
useEffect(() => {
if (session?.user?.id) {
if (!activeWorkflowId) {
setDeployedState(null)
setIsLoadingDeployedState(false)
return
}
if (isRegistryLoading) {
setDeployedState(null)
setIsLoadingDeployedState(false)
return
}
if (isDeployed) {
setNeedsRedeploymentFlag(false)
fetchDeployedState()
} else {
setDeployedState(null)
setIsLoadingDeployedState(false)
}
}, [activeWorkflowId, isDeployed, setNeedsRedeploymentFlag, isRegistryLoading])
// Get current store state for change detection
const currentBlocks = useWorkflowStore((state) => state.blocks)
const subBlockValues = useSubBlockStore((state) =>
activeWorkflowId ? state.workflowValues[activeWorkflowId] : null
)
/**
* Normalize blocks for semantic comparison - only compare what matters functionally
* Ignores: IDs, positions, dimensions, metadata that don't affect workflow logic
* Compares: type, name, subBlock values
*/
const normalizeBlocksForComparison = (blocks: Record<string, any>) => {
if (!blocks) return []
return Object.values(blocks)
.map((block: any) => ({
type: block.type,
name: block.name,
subBlocks: block.subBlocks || {},
}))
.sort((a, b) => {
const typeA = a.type || ''
const typeB = b.type || ''
if (typeA !== typeB) return typeA.localeCompare(typeB)
return (a.name || '').localeCompare(b.name || '')
})
}
useEffect(() => {
if (!activeWorkflowId || !deployedState) {
setChangeDetected(false)
return
}
if (isLoadingDeployedState) {
return
}
const currentMergedState = mergeSubblockState(currentBlocks, activeWorkflowId)
const deployedBlocks = deployedState?.blocks
if (!deployedBlocks) {
setChangeDetected(false)
return
}
const normalizedCurrentBlocks = normalizeBlocksForComparison(currentMergedState)
const normalizedDeployedBlocks = normalizeBlocksForComparison(deployedBlocks)
const hasChanges =
JSON.stringify(normalizedCurrentBlocks) !== JSON.stringify(normalizedDeployedBlocks)
setChangeDetected(hasChanges)
}, [activeWorkflowId, deployedState, currentBlocks, subBlockValues, isLoadingDeployedState])
useEffect(() => {
if (session?.user?.id && !isRegistryLoading) {
checkUserUsage(session.user.id).then((usage) => {
if (usage) {
setUsageExceeded(usage.isExceeded)
@@ -222,7 +336,7 @@ export function ControlBar() {
}
})
}
}, [session?.user?.id, completedRuns])
}, [session?.user?.id, completedRuns, isRegistryLoading])
/**
* Check user usage data with caching to prevent excessive API calls
@@ -532,8 +646,11 @@ export function ControlBar() {
const renderDeployButton = () => (
<DeploymentControls
activeWorkflowId={activeWorkflowId}
needsRedeployment={needsRedeployment}
setNeedsRedeployment={setNeedsRedeployment}
needsRedeployment={changeDetected}
setNeedsRedeployment={setChangeDetected}
deployedState={deployedState}
isLoadingDeployedState={isLoadingDeployedState}
refetchDeployedState={fetchDeployedState}
/>
)

View File

@@ -21,6 +21,7 @@ interface LoopNodeData {
loopType?: 'for' | 'forEach'
count?: number
collection?: string | any[] | Record<string, any>
isPreview?: boolean
executionState?: {
currentIteration: number
isExecuting: boolean
@@ -35,6 +36,9 @@ interface LoopBadgesProps {
}
export function LoopBadges({ nodeId, data }: LoopBadgesProps) {
// Check if this is preview mode
const isPreview = data?.isPreview || false
// State
const [loopType, setLoopType] = useState(data?.loopType || 'for')
const [iterations, setIterations] = useState(data?.count || 5)
@@ -50,6 +54,8 @@ export function LoopBadges({ nodeId, data }: LoopBadgesProps) {
// Get store methods
const updateNodeData = useCallback(
(updates: Partial<LoopNodeData>) => {
if (isPreview) return // Don't update in preview mode
useWorkflowStore.setState((state) => ({
blocks: {
...state.blocks,
@@ -63,7 +69,7 @@ export function LoopBadges({ nodeId, data }: LoopBadgesProps) {
},
}))
},
[nodeId]
[nodeId, isPreview]
)
const updateLoopType = useWorkflowStore((state) => state.updateLoopType)
@@ -94,27 +100,36 @@ export function LoopBadges({ nodeId, data }: LoopBadgesProps) {
// Handle loop type change
const handleLoopTypeChange = useCallback(
(newType: 'for' | 'forEach') => {
if (isPreview) return // Don't allow changes in preview mode
setLoopType(newType)
updateLoopType(nodeId, newType)
setTypePopoverOpen(false)
},
[nodeId, updateLoopType]
[nodeId, updateLoopType, isPreview]
)
// Handle iterations input change
const handleIterationsChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const sanitizedValue = e.target.value.replace(/[^0-9]/g, '')
const numValue = Number.parseInt(sanitizedValue)
const handleIterationsChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (isPreview) return // Don't allow changes in preview mode
if (!Number.isNaN(numValue)) {
setInputValue(Math.min(100, numValue).toString())
} else {
setInputValue(sanitizedValue)
}
}, [])
const sanitizedValue = e.target.value.replace(/[^0-9]/g, '')
const numValue = Number.parseInt(sanitizedValue)
if (!Number.isNaN(numValue)) {
setInputValue(Math.min(100, numValue).toString())
} else {
setInputValue(sanitizedValue)
}
},
[isPreview]
)
// Handle iterations save
const handleIterationsSave = useCallback(() => {
if (isPreview) return // Don't allow changes in preview mode
const value = Number.parseInt(inputValue)
if (!Number.isNaN(value)) {
@@ -126,11 +141,13 @@ export function LoopBadges({ nodeId, data }: LoopBadgesProps) {
setInputValue(iterations.toString())
}
setConfigPopoverOpen(false)
}, [inputValue, iterations, nodeId, updateLoopCount])
}, [inputValue, iterations, nodeId, updateLoopCount, isPreview])
// Handle editor change with tag dropdown support
const handleEditorChange = useCallback(
(value: string) => {
if (isPreview) return // Don't allow changes in preview mode
setEditorValue(value)
updateLoopCollection(nodeId, value)
@@ -146,12 +163,14 @@ export function LoopBadges({ nodeId, data }: LoopBadgesProps) {
setShowTagDropdown(triggerCheck.show)
}
},
[nodeId, updateLoopCollection]
[nodeId, updateLoopCollection, isPreview]
)
// Handle tag selection
const handleTagSelect = useCallback(
(newValue: string) => {
if (isPreview) return // Don't allow changes in preview mode
setEditorValue(newValue)
updateLoopCollection(nodeId, newValue)
setShowTagDropdown(false)
@@ -164,137 +183,149 @@ export function LoopBadges({ nodeId, data }: LoopBadgesProps) {
}
}, 0)
},
[nodeId, updateLoopCollection]
[nodeId, updateLoopCollection, isPreview]
)
return (
<div className='-top-9 absolute right-0 left-0 z-10 flex justify-between'>
{/* Loop Type Badge */}
<Popover open={typePopoverOpen} onOpenChange={setTypePopoverOpen}>
<Popover
open={!isPreview && typePopoverOpen}
onOpenChange={isPreview ? undefined : setTypePopoverOpen}
>
<PopoverTrigger asChild onClick={(e) => e.stopPropagation()}>
<Badge
variant='outline'
className={cn(
'border-border bg-background/80 py-0.5 pr-1.5 pl-2.5 font-medium text-foreground text-sm backdrop-blur-sm',
'cursor-pointer transition-colors duration-150 hover:bg-accent/50',
!isPreview && 'cursor-pointer transition-colors duration-150 hover:bg-accent/50',
'flex items-center gap-1'
)}
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
>
{loopType === 'for' ? 'For Loop' : 'For Each'}
<ChevronDown className='h-3 w-3 text-muted-foreground' />
{!isPreview && <ChevronDown className='h-3 w-3 text-muted-foreground' />}
</Badge>
</PopoverTrigger>
<PopoverContent className='w-48 p-3' align='center' onClick={(e) => e.stopPropagation()}>
<div className='space-y-2'>
<div className='font-medium text-muted-foreground text-xs'>Loop Type</div>
<div className='space-y-1'>
<div
className={cn(
'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5',
loopType === 'for' ? 'bg-accent' : 'hover:bg-accent/50'
)}
onClick={() => handleLoopTypeChange('for')}
>
<span className='text-sm'>For Loop</span>
</div>
<div
className={cn(
'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5',
loopType === 'forEach' ? 'bg-accent' : 'hover:bg-accent/50'
)}
onClick={() => handleLoopTypeChange('forEach')}
>
<span className='text-sm'>For Each</span>
{!isPreview && (
<PopoverContent className='w-48 p-3' align='center' onClick={(e) => e.stopPropagation()}>
<div className='space-y-2'>
<div className='font-medium text-muted-foreground text-xs'>Loop Type</div>
<div className='space-y-1'>
<div
className={cn(
'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5',
loopType === 'for' ? 'bg-accent' : 'hover:bg-accent/50'
)}
onClick={() => handleLoopTypeChange('for')}
>
<span className='text-sm'>For Loop</span>
</div>
<div
className={cn(
'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5',
loopType === 'forEach' ? 'bg-accent' : 'hover:bg-accent/50'
)}
onClick={() => handleLoopTypeChange('forEach')}
>
<span className='text-sm'>For Each</span>
</div>
</div>
</div>
</div>
</PopoverContent>
</PopoverContent>
)}
</Popover>
{/* Iterations/Collection Badge */}
<Popover open={configPopoverOpen} onOpenChange={setConfigPopoverOpen}>
<Popover
open={!isPreview && configPopoverOpen}
onOpenChange={isPreview ? undefined : setConfigPopoverOpen}
>
<PopoverTrigger asChild onClick={(e) => e.stopPropagation()}>
<Badge
variant='outline'
className={cn(
'border-border bg-background/80 py-0.5 pr-1.5 pl-2.5 font-medium text-foreground text-sm backdrop-blur-sm',
'cursor-pointer transition-colors duration-150 hover:bg-accent/50',
!isPreview && 'cursor-pointer transition-colors duration-150 hover:bg-accent/50',
'flex items-center gap-1'
)}
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
>
{loopType === 'for' ? `Iterations: ${iterations}` : 'Items'}
<ChevronDown className='h-3 w-3 text-muted-foreground' />
{!isPreview && <ChevronDown className='h-3 w-3 text-muted-foreground' />}
</Badge>
</PopoverTrigger>
<PopoverContent
className={cn('p-3', loopType !== 'for' ? 'w-72' : 'w-48')}
align='center'
onClick={(e) => e.stopPropagation()}
>
<div className='space-y-2'>
<div className='font-medium text-muted-foreground text-xs'>
{loopType === 'for' ? 'Loop Iterations' : 'Collection Items'}
</div>
{loopType === 'for' ? (
// Number input for 'for' loops
<div className='flex items-center gap-2'>
<Input
type='text'
value={inputValue}
onChange={handleIterationsChange}
onBlur={handleIterationsSave}
onKeyDown={(e) => e.key === 'Enter' && handleIterationsSave()}
className='h-8 text-sm'
autoFocus
/>
{!isPreview && (
<PopoverContent
className={cn('p-3', loopType !== 'for' ? 'w-72' : 'w-48')}
align='center'
onClick={(e) => e.stopPropagation()}
>
<div className='space-y-2'>
<div className='font-medium text-muted-foreground text-xs'>
{loopType === 'for' ? 'Loop Iterations' : 'Collection Items'}
</div>
) : (
// Code editor for 'forEach' loops
<div ref={editorContainerRef} className='relative'>
<div className='relative min-h-[80px] rounded-md border border-input bg-background px-3 pt-2 pb-3 font-mono text-sm'>
{editorValue === '' && (
<div className='pointer-events-none absolute top-[8.5px] left-3 select-none text-muted-foreground/50'>
["item1", "item2", "item3"]
</div>
{loopType === 'for' ? (
// Number input for 'for' loops
<div className='flex items-center gap-2'>
<Input
type='text'
value={inputValue}
onChange={handleIterationsChange}
onBlur={handleIterationsSave}
onKeyDown={(e) => e.key === 'Enter' && handleIterationsSave()}
className='h-8 text-sm'
autoFocus
/>
</div>
) : (
// Code editor for 'forEach' loops
<div ref={editorContainerRef} className='relative'>
<div className='relative min-h-[80px] rounded-md border border-input bg-background px-3 pt-2 pb-3 font-mono text-sm'>
{editorValue === '' && (
<div className='pointer-events-none absolute top-[8.5px] left-3 select-none text-muted-foreground/50'>
["item1", "item2", "item3"]
</div>
)}
<Editor
value={editorValue}
onValueChange={handleEditorChange}
highlight={(code) => highlight(code, languages.javascript, 'javascript')}
padding={0}
style={{
fontFamily: 'monospace',
lineHeight: '21px',
}}
className='w-full focus:outline-none'
textareaClassName='focus:outline-none focus:ring-0 bg-transparent resize-none w-full overflow-hidden whitespace-pre-wrap'
/>
</div>
<div className='mt-2 text-[10px] text-muted-foreground'>
Array or object to iterate over. Type "{'<'}" to reference other blocks.
</div>
{showTagDropdown && (
<TagDropdown
visible={showTagDropdown}
onSelect={handleTagSelect}
blockId={nodeId}
activeSourceBlockId={null}
inputValue={editorValue}
cursorPosition={cursorPosition}
onClose={() => setShowTagDropdown(false)}
/>
)}
<Editor
value={editorValue}
onValueChange={handleEditorChange}
highlight={(code) => highlight(code, languages.javascript, 'javascript')}
padding={0}
style={{
fontFamily: 'monospace',
lineHeight: '21px',
}}
className='w-full focus:outline-none'
textareaClassName='focus:outline-none focus:ring-0 bg-transparent resize-none w-full overflow-hidden whitespace-pre-wrap'
/>
</div>
<div className='mt-2 text-[10px] text-muted-foreground'>
Array or object to iterate over. Type "{'<'}" to reference other blocks.
</div>
{showTagDropdown && (
<TagDropdown
visible={showTagDropdown}
onSelect={handleTagSelect}
blockId={nodeId}
activeSourceBlockId={null}
inputValue={editorValue}
cursorPosition={cursorPosition}
onClose={() => setShowTagDropdown(false)}
/>
)}
</div>
)}
)}
{loopType === 'for' && (
<div className='text-[10px] text-muted-foreground'>
Enter a number between 1 and 100
</div>
)}
</div>
</PopoverContent>
{loopType === 'for' && (
<div className='text-[10px] text-muted-foreground'>
Enter a number between 1 and 100
</div>
)}
</div>
</PopoverContent>
)}
</Popover>
</div>
)

View File

@@ -72,6 +72,9 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => {
const removeBlock = useWorkflowStore((state) => state.removeBlock)
const blockRef = useRef<HTMLDivElement>(null)
// Check if this is preview mode
const isPreview = data?.isPreview || false
// Determine nesting level by counting parents
const nestingLevel = useMemo(() => {
let level = 0
@@ -91,7 +94,7 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => {
const getNestedStyles = () => {
// Base styles
const styles: Record<string, string> = {
backgroundColor: data?.state === 'valid' ? 'rgba(34,197,94,0.05)' : 'transparent',
backgroundColor: 'transparent',
}
// Apply nested styles
@@ -118,7 +121,7 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => {
' relative cursor-default select-none',
'transition-block-bg transition-ring',
'z-[20]',
data?.state === 'valid' && 'bg-[rgba(34,197,94,0.05)] ring-2 ring-[#2FB3FF]',
data?.state === 'valid',
nestingLevel > 0 &&
`border border-[0.5px] ${nestingLevel % 2 === 0 ? 'border-slate-300/60' : 'border-slate-400/60'}`
)}
@@ -128,23 +131,27 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => {
position: 'relative',
overflow: 'visible',
...nestedStyles,
pointerEvents: 'all',
pointerEvents: isPreview ? 'none' : 'all',
}}
data-node-id={id}
data-type='loopNode'
data-nesting-level={nestingLevel}
>
{/* Critical drag handle that controls only the loop node movement */}
<div
className='workflow-drag-handle absolute top-0 right-0 left-0 z-10 h-10 cursor-move'
style={{ pointerEvents: 'auto' }}
/>
{!isPreview && (
<div
className='workflow-drag-handle absolute top-0 right-0 left-0 z-10 h-10 cursor-move'
style={{ pointerEvents: 'auto' }}
/>
)}
{/* Custom visible resize handle */}
<div
className='absolute right-2 bottom-2 z-20 flex h-8 w-8 cursor-se-resize items-center justify-center text-muted-foreground'
style={{ pointerEvents: 'auto' }}
/>
{!isPreview && (
<div
className='absolute right-2 bottom-2 z-20 flex h-8 w-8 cursor-se-resize items-center justify-center text-muted-foreground'
style={{ pointerEvents: 'auto' }}
/>
)}
{/* Child nodes container - Enable pointer events to allow dragging of children */}
<div
@@ -153,27 +160,29 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => {
style={{
position: 'relative',
minHeight: '100%',
pointerEvents: 'auto',
pointerEvents: isPreview ? 'none' : 'auto',
}}
>
{/* Delete button - styled like in action-bar.tsx */}
<Button
variant='ghost'
size='sm'
onClick={(e) => {
e.stopPropagation()
removeBlock(id)
}}
className='absolute top-2 right-2 z-20 text-gray-500 opacity-0 transition-opacity duration-200 hover:text-red-600 group-hover:opacity-100'
style={{ pointerEvents: 'auto' }}
>
<Trash2 className='h-4 w-4' />
</Button>
{!isPreview && (
<Button
variant='ghost'
size='sm'
onClick={(e) => {
e.stopPropagation()
removeBlock(id)
}}
className='absolute top-2 right-2 z-20 text-gray-500 opacity-0 transition-opacity duration-200 hover:text-red-600 group-hover:opacity-100'
style={{ pointerEvents: 'auto' }}
>
<Trash2 className='h-4 w-4' />
</Button>
)}
{/* Loop Start Block */}
<div
className='-translate-y-1/2 absolute top-1/2 left-8 flex h-10 w-10 transform items-center justify-center rounded-md bg-[#2FB3FF] p-2'
style={{ pointerEvents: 'auto' }}
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
data-parent-id={id}
data-node-role='loop-start'
data-extent='parent'

View File

@@ -42,7 +42,11 @@ export function OutputSelect({
// Skip starter/start blocks
if (block.type === 'starter') return
const blockName = block.name.replace(/\s+/g, '').toLowerCase()
// Add defensive check to ensure block.name exists and is a string
const blockName =
block.name && typeof block.name === 'string'
? block.name.replace(/\s+/g, '').toLowerCase()
: `block-${block.id}`
// Add response outputs
if (block.outputs && typeof block.outputs === 'object') {
@@ -60,7 +64,7 @@ export function OutputSelect({
id: `${block.id}_${fullPath}`,
label: `${blockName}.${fullPath}`,
blockId: block.id,
blockName: block.name,
blockName: block.name || `Block ${block.id}`,
blockType: block.type,
path: fullPath,
})
@@ -93,7 +97,12 @@ export function OutputSelect({
if (validOutputs.length === 1) {
const output = workflowOutputs.find((o) => o.id === validOutputs[0])
if (output) {
return `${output.blockName.replace(/\s+/g, '').toLowerCase()}.${output.path}`
// Add defensive check for output.blockName
const blockNameText =
output.blockName && typeof output.blockName === 'string'
? output.blockName.replace(/\s+/g, '').toLowerCase()
: `block-${output.blockId}`
return `${blockNameText}.${output.path}`
}
return placeholder
}

View File

@@ -21,6 +21,7 @@ interface ParallelNodeData {
parallelType?: 'count' | 'collection'
count?: number
collection?: string | any[] | Record<string, any>
isPreview?: boolean
executionState?: {
currentExecution: number
isExecuting: boolean
@@ -35,6 +36,9 @@ interface ParallelBadgesProps {
}
export function ParallelBadges({ nodeId, data }: ParallelBadgesProps) {
// Check if this is preview mode
const isPreview = data?.isPreview || false
// State
const [parallelType, setParallelType] = useState<'count' | 'collection'>(
data?.parallelType || 'collection'
@@ -56,6 +60,8 @@ export function ParallelBadges({ nodeId, data }: ParallelBadgesProps) {
// Update node data to include parallel type
const updateNodeData = useCallback(
(updates: Partial<ParallelNodeData>) => {
if (isPreview) return // Don't update in preview mode
useWorkflowStore.setState((state) => ({
blocks: {
...state.blocks,
@@ -69,7 +75,7 @@ export function ParallelBadges({ nodeId, data }: ParallelBadgesProps) {
},
}))
},
[nodeId]
[nodeId, isPreview]
)
// Initialize state from data when it changes
@@ -94,6 +100,8 @@ export function ParallelBadges({ nodeId, data }: ParallelBadgesProps) {
// Handle parallel type change
const handleParallelTypeChange = useCallback(
(newType: 'count' | 'collection') => {
if (isPreview) return // Don't allow changes in preview mode
setParallelType(newType)
updateNodeData({ parallelType: newType })
@@ -108,23 +116,38 @@ export function ParallelBadges({ nodeId, data }: ParallelBadgesProps) {
setTypePopoverOpen(false)
},
[nodeId, iterations, editorValue, updateNodeData, updateParallelCount, updateParallelCollection]
[
nodeId,
iterations,
editorValue,
updateNodeData,
updateParallelCount,
updateParallelCollection,
isPreview,
]
)
// Handle iterations input change
const handleIterationsChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const sanitizedValue = e.target.value.replace(/[^0-9]/g, '')
const numValue = Number.parseInt(sanitizedValue)
const handleIterationsChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (isPreview) return // Don't allow changes in preview mode
if (!Number.isNaN(numValue)) {
setInputValue(Math.min(20, numValue).toString())
} else {
setInputValue(sanitizedValue)
}
}, [])
const sanitizedValue = e.target.value.replace(/[^0-9]/g, '')
const numValue = Number.parseInt(sanitizedValue)
if (!Number.isNaN(numValue)) {
setInputValue(Math.min(20, numValue).toString())
} else {
setInputValue(sanitizedValue)
}
},
[isPreview]
)
// Handle iterations save
const handleIterationsSave = useCallback(() => {
if (isPreview) return // Don't allow changes in preview mode
const value = Number.parseInt(inputValue)
if (!Number.isNaN(value)) {
@@ -136,11 +159,13 @@ export function ParallelBadges({ nodeId, data }: ParallelBadgesProps) {
setInputValue(iterations.toString())
}
setConfigPopoverOpen(false)
}, [inputValue, iterations, nodeId, updateParallelCount])
}, [inputValue, iterations, nodeId, updateParallelCount, isPreview])
// Handle editor change and check for tag trigger
const handleEditorChange = useCallback(
(value: string) => {
if (isPreview) return // Don't allow changes in preview mode
setEditorValue(value)
updateParallelCollection(nodeId, value)
@@ -156,12 +181,14 @@ export function ParallelBadges({ nodeId, data }: ParallelBadgesProps) {
setShowTagDropdown(tagTrigger.show)
}
},
[nodeId, updateParallelCollection]
[nodeId, updateParallelCollection, isPreview]
)
// Handle tag selection
const handleTagSelect = useCallback(
(newValue: string) => {
if (isPreview) return // Don't allow changes in preview mode
setEditorValue(newValue)
updateParallelCollection(nodeId, newValue)
setShowTagDropdown(false)
@@ -174,7 +201,7 @@ export function ParallelBadges({ nodeId, data }: ParallelBadgesProps) {
}
}, 0)
},
[nodeId, updateParallelCollection]
[nodeId, updateParallelCollection, isPreview]
)
// Handle key events
@@ -187,141 +214,153 @@ export function ParallelBadges({ nodeId, data }: ParallelBadgesProps) {
return (
<div className='-top-9 absolute right-0 left-0 z-10 flex justify-between'>
{/* Parallel Type Badge */}
<Popover open={typePopoverOpen} onOpenChange={setTypePopoverOpen}>
<Popover
open={!isPreview && typePopoverOpen}
onOpenChange={isPreview ? undefined : setTypePopoverOpen}
>
<PopoverTrigger asChild onClick={(e) => e.stopPropagation()}>
<Badge
variant='outline'
className={cn(
'border-border bg-background/80 py-0.5 pr-1.5 pl-2.5 font-medium text-foreground text-sm backdrop-blur-sm',
'cursor-pointer transition-colors duration-150 hover:bg-accent/50',
!isPreview && 'cursor-pointer transition-colors duration-150 hover:bg-accent/50',
'flex items-center gap-1'
)}
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
>
{parallelType === 'count' ? 'Parallel Count' : 'Parallel Each'}
<ChevronDown className='h-3 w-3 text-muted-foreground' />
{!isPreview && <ChevronDown className='h-3 w-3 text-muted-foreground' />}
</Badge>
</PopoverTrigger>
<PopoverContent className='w-48 p-3' align='center' onClick={(e) => e.stopPropagation()}>
<div className='space-y-2'>
<div className='font-medium text-muted-foreground text-xs'>Parallel Type</div>
<div className='space-y-1'>
<div
className={cn(
'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5',
parallelType === 'count' ? 'bg-accent' : 'hover:bg-accent/50'
)}
onClick={() => handleParallelTypeChange('count')}
>
<span className='text-sm'>Parallel Count</span>
</div>
<div
className={cn(
'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5',
parallelType === 'collection' ? 'bg-accent' : 'hover:bg-accent/50'
)}
onClick={() => handleParallelTypeChange('collection')}
>
<span className='text-sm'>Parallel Each</span>
{!isPreview && (
<PopoverContent className='w-48 p-3' align='center' onClick={(e) => e.stopPropagation()}>
<div className='space-y-2'>
<div className='font-medium text-muted-foreground text-xs'>Parallel Type</div>
<div className='space-y-1'>
<div
className={cn(
'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5',
parallelType === 'count' ? 'bg-accent' : 'hover:bg-accent/50'
)}
onClick={() => handleParallelTypeChange('count')}
>
<span className='text-sm'>Parallel Count</span>
</div>
<div
className={cn(
'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5',
parallelType === 'collection' ? 'bg-accent' : 'hover:bg-accent/50'
)}
onClick={() => handleParallelTypeChange('collection')}
>
<span className='text-sm'>Parallel Each</span>
</div>
</div>
</div>
</div>
</PopoverContent>
</PopoverContent>
)}
</Popover>
{/* Iterations/Collection Badge */}
<Popover open={configPopoverOpen} onOpenChange={setConfigPopoverOpen}>
<Popover
open={!isPreview && configPopoverOpen}
onOpenChange={isPreview ? undefined : setConfigPopoverOpen}
>
<PopoverTrigger asChild onClick={(e) => e.stopPropagation()}>
<Badge
variant='outline'
className={cn(
'border-border bg-background/80 py-0.5 pr-1.5 pl-2.5 font-medium text-foreground text-sm backdrop-blur-sm',
'cursor-pointer transition-colors duration-150 hover:bg-accent/50',
!isPreview && 'cursor-pointer transition-colors duration-150 hover:bg-accent/50',
'flex items-center gap-1'
)}
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
>
{parallelType === 'count' ? `Iterations: ${iterations}` : 'Items'}
<ChevronDown className='h-3 w-3 text-muted-foreground' />
{!isPreview && <ChevronDown className='h-3 w-3 text-muted-foreground' />}
</Badge>
</PopoverTrigger>
<PopoverContent
className={cn('p-3', parallelType !== 'count' ? 'w-72' : 'w-48')}
align='center'
onClick={(e) => e.stopPropagation()}
onKeyDown={handleKeyDown}
>
<div className='space-y-2'>
<div className='font-medium text-muted-foreground text-xs'>
{parallelType === 'count' ? 'Parallel Iterations' : 'Parallel Items'}
</div>
{parallelType === 'count' ? (
// Number input for count-based parallel
<div className='flex items-center gap-2'>
<Input
type='text'
value={inputValue}
onChange={handleIterationsChange}
onBlur={handleIterationsSave}
onKeyDown={(e) => e.key === 'Enter' && handleIterationsSave()}
className='h-8 text-sm'
autoFocus
/>
{!isPreview && (
<PopoverContent
className={cn('p-3', parallelType !== 'count' ? 'w-72' : 'w-48')}
align='center'
onClick={(e) => e.stopPropagation()}
onKeyDown={handleKeyDown}
>
<div className='space-y-2'>
<div className='font-medium text-muted-foreground text-xs'>
{parallelType === 'count' ? 'Parallel Iterations' : 'Parallel Items'}
</div>
) : (
// Code editor for collection-based parallel
<div className='relative'>
<div
ref={editorContainerRef}
className='relative min-h-[80px] rounded-md border border-input bg-background px-3 pt-2 pb-3 font-mono text-sm'
>
{editorValue === '' && (
<div className='pointer-events-none absolute top-[8.5px] left-3 select-none text-muted-foreground/50'>
['item1', 'item2', 'item3']
</div>
{parallelType === 'count' ? (
// Number input for count-based parallel
<div className='flex items-center gap-2'>
<Input
type='text'
value={inputValue}
onChange={handleIterationsChange}
onBlur={handleIterationsSave}
onKeyDown={(e) => e.key === 'Enter' && handleIterationsSave()}
className='h-8 text-sm'
autoFocus
/>
</div>
) : (
// Code editor for collection-based parallel
<div className='relative'>
<div
ref={editorContainerRef}
className='relative min-h-[80px] rounded-md border border-input bg-background px-3 pt-2 pb-3 font-mono text-sm'
>
{editorValue === '' && (
<div className='pointer-events-none absolute top-[8.5px] left-3 select-none text-muted-foreground/50'>
['item1', 'item2', 'item3']
</div>
)}
<Editor
value={editorValue}
onValueChange={handleEditorChange}
highlight={(code) => highlight(code, languages.javascript, 'javascript')}
padding={0}
style={{
fontFamily: 'monospace',
lineHeight: '21px',
}}
className='w-full focus:outline-none'
textareaClassName='focus:outline-none focus:ring-0 bg-transparent resize-none w-full overflow-hidden whitespace-pre-wrap'
onKeyDown={(e) => {
if (e.key === 'Escape') {
setShowTagDropdown(false)
}
}}
/>
</div>
<div className='mt-2 text-[10px] text-muted-foreground'>
Array or object to use for parallel execution. Type "{'<'}" to reference other
blocks.
</div>
{showTagDropdown && (
<TagDropdown
visible={showTagDropdown}
onSelect={handleTagSelect}
blockId={nodeId}
activeSourceBlockId={null}
inputValue={editorValue}
cursorPosition={cursorPosition}
onClose={() => setShowTagDropdown(false)}
/>
)}
<Editor
value={editorValue}
onValueChange={handleEditorChange}
highlight={(code) => highlight(code, languages.javascript, 'javascript')}
padding={0}
style={{
fontFamily: 'monospace',
lineHeight: '21px',
}}
className='w-full focus:outline-none'
textareaClassName='focus:outline-none focus:ring-0 bg-transparent resize-none w-full overflow-hidden whitespace-pre-wrap'
onKeyDown={(e) => {
if (e.key === 'Escape') {
setShowTagDropdown(false)
}
}}
/>
</div>
<div className='mt-2 text-[10px] text-muted-foreground'>
Array or object to use for parallel execution. Type "{'<'}" to reference other
blocks.
</div>
{showTagDropdown && (
<TagDropdown
visible={showTagDropdown}
onSelect={handleTagSelect}
blockId={nodeId}
activeSourceBlockId={null}
inputValue={editorValue}
cursorPosition={cursorPosition}
onClose={() => setShowTagDropdown(false)}
/>
)}
</div>
)}
)}
{parallelType === 'count' && (
<div className='text-[10px] text-muted-foreground'>
Enter a number between 1 and 20
</div>
)}
</div>
</PopoverContent>
{parallelType === 'count' && (
<div className='text-[10px] text-muted-foreground'>
Enter a number between 1 and 20
</div>
)}
</div>
</PopoverContent>
)}
</Popover>
</div>
)

View File

@@ -88,6 +88,9 @@ export const ParallelNodeComponent = memo(({ data, selected, id }: NodeProps) =>
const { getNodes } = useReactFlow()
const blockRef = useRef<HTMLDivElement>(null)
// Check if this is preview mode
const isPreview = data?.isPreview || false
// Determine nesting level by counting parents
const nestingLevel = useMemo(() => {
const maxDepth = 100 // Prevent infinite loops
@@ -108,7 +111,7 @@ export const ParallelNodeComponent = memo(({ data, selected, id }: NodeProps) =>
const getNestedStyles = () => {
// Base styles
const styles: Record<string, string> = {
backgroundColor: data?.state === 'valid' ? 'rgba(139, 195, 74, 0.05)' : 'transparent',
backgroundColor: 'transparent',
}
// Apply nested styles
@@ -135,7 +138,7 @@ export const ParallelNodeComponent = memo(({ data, selected, id }: NodeProps) =>
'relative cursor-default select-none',
'transition-block-bg transition-ring',
'z-[20]',
data?.state === 'valid' && 'bg-[rgba(139,195,74,0.05)] ring-2 ring-[#8BC34A]',
data?.state === 'valid',
nestingLevel > 0 &&
`border border-[0.5px] ${nestingLevel % 2 === 0 ? 'border-slate-300/60' : 'border-slate-400/60'}`
)}
@@ -145,23 +148,27 @@ export const ParallelNodeComponent = memo(({ data, selected, id }: NodeProps) =>
position: 'relative',
overflow: 'visible',
...nestedStyles,
pointerEvents: 'all',
pointerEvents: isPreview ? 'none' : 'all',
}}
data-node-id={id}
data-type='parallelNode'
data-nesting-level={nestingLevel}
>
{/* Critical drag handle that controls only the parallel node movement */}
<div
className='workflow-drag-handle absolute top-0 right-0 left-0 z-10 h-10 cursor-move'
style={{ pointerEvents: 'auto' }}
/>
{!isPreview && (
<div
className='workflow-drag-handle absolute top-0 right-0 left-0 z-10 h-10 cursor-move'
style={{ pointerEvents: 'auto' }}
/>
)}
{/* Custom visible resize handle */}
<div
className='absolute right-2 bottom-2 z-20 flex h-8 w-8 cursor-se-resize items-center justify-center text-muted-foreground'
style={{ pointerEvents: 'auto' }}
/>
{!isPreview && (
<div
className='absolute right-2 bottom-2 z-20 flex h-8 w-8 cursor-se-resize items-center justify-center text-muted-foreground'
style={{ pointerEvents: 'auto' }}
/>
)}
{/* Child nodes container - Set pointerEvents to allow dragging of children */}
<div
@@ -170,27 +177,29 @@ export const ParallelNodeComponent = memo(({ data, selected, id }: NodeProps) =>
style={{
position: 'relative',
minHeight: '100%',
pointerEvents: 'auto',
pointerEvents: isPreview ? 'none' : 'auto',
}}
>
{/* Delete button - styled like in action-bar.tsx */}
<Button
variant='ghost'
size='sm'
onClick={(e) => {
e.stopPropagation()
useWorkflowStore.getState().removeBlock(id)
}}
className='absolute top-2 right-2 z-20 text-gray-500 opacity-0 transition-opacity duration-200 hover:text-red-600 group-hover:opacity-100'
style={{ pointerEvents: 'auto' }}
>
<Trash2 className='h-4 w-4' />
</Button>
{!isPreview && (
<Button
variant='ghost'
size='sm'
onClick={(e) => {
e.stopPropagation()
useWorkflowStore.getState().removeBlock(id)
}}
className='absolute top-2 right-2 z-20 text-gray-500 opacity-0 transition-opacity duration-200 hover:text-red-600 group-hover:opacity-100'
style={{ pointerEvents: 'auto' }}
>
<Trash2 className='h-4 w-4' />
</Button>
)}
{/* Parallel Start Block */}
<div
className='-translate-y-1/2 absolute top-1/2 left-8 flex h-10 w-10 transform items-center justify-center rounded-md bg-[#8BC34A] p-2'
style={{ pointerEvents: 'auto' }}
className='-translate-y-1/2 absolute top-1/2 left-8 flex h-10 w-10 transform items-center justify-center rounded-md bg-[#FEE12B] p-2'
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
data-parent-id={id}
data-node-role='parallel-start'
data-extent='parent'

View File

@@ -11,7 +11,9 @@ interface ChannelSelectorInputProps {
subBlock: SubBlockConfig
disabled?: boolean
onChannelSelect?: (channelId: string) => void
credential?: string // Optional credential override
credential?: string
isPreview?: boolean
previewValue?: any | null
}
export function ChannelSelectorInput({
@@ -20,6 +22,8 @@ export function ChannelSelectorInput({
disabled = false,
onChannelSelect,
credential: providedCredential,
isPreview = false,
previewValue,
}: ChannelSelectorInputProps) {
const { getValue, setValue } = useSubBlockStore()
const [selectedChannelId, setSelectedChannelId] = useState<string>('')
@@ -42,19 +46,31 @@ export function ChannelSelectorInput({
credential = (getValue(blockId, 'credential') as string) || ''
}
// Get the current value from the store
// Use preview value when in preview mode, otherwise use store value
const value = isPreview ? previewValue : getValue(blockId, subBlock.id)
// Get the current value from the store or prop value if in preview mode
useEffect(() => {
const value = getValue(blockId, subBlock.id)
if (value && typeof value === 'string') {
setSelectedChannelId(value)
if (isPreview && previewValue !== undefined) {
const value = previewValue
if (value && typeof value === 'string') {
setSelectedChannelId(value)
}
} else {
const value = getValue(blockId, subBlock.id)
if (value && typeof value === 'string') {
setSelectedChannelId(value)
}
}
}, [blockId, subBlock.id, getValue])
}, [blockId, subBlock.id, getValue, isPreview, previewValue])
// Handle channel selection
const handleChannelChange = (channelId: string, info?: SlackChannelInfo) => {
setSelectedChannelId(channelId)
setChannelInfo(info || null)
setValue(blockId, subBlock.id, channelId)
if (!isPreview) {
setValue(blockId, subBlock.id, channelId)
}
onChannelSelect?.(channelId)
}

View File

@@ -9,19 +9,45 @@ interface CheckboxListProps {
title: string
options: { label: string; id: string }[]
layout?: 'full' | 'half'
isPreview?: boolean
subBlockValues?: Record<string, any>
}
export function CheckboxList({ blockId, subBlockId, title, options, layout }: CheckboxListProps) {
export function CheckboxList({
blockId,
subBlockId,
title,
options,
layout,
isPreview = false,
subBlockValues,
}: CheckboxListProps) {
return (
<div className={cn('grid gap-4', layout === 'half' ? 'grid-cols-2' : 'grid-cols-1', 'pt-1')}>
{options.map((option) => {
const [value, setValue] = useSubBlockValue(blockId, option.id)
const [storeValue, setStoreValue] = useSubBlockValue(blockId, option.id)
// Get preview value for this specific option
const previewValue =
isPreview && subBlockValues ? subBlockValues[option.id]?.value : undefined
// Use preview value when in preview mode, otherwise use store value
const value = isPreview ? previewValue : storeValue
const handleChange = (checked: boolean) => {
// Only update store when not in preview mode
if (!isPreview) {
setStoreValue(checked)
}
}
return (
<div key={option.id} className='flex items-center space-x-2'>
<Checkbox
id={`${blockId}-${option.id}`}
checked={Boolean(value)}
onCheckedChange={(checked) => setValue(checked as boolean)}
onCheckedChange={handleChange}
disabled={isPreview}
/>
<Label
htmlFor={`${blockId}-${option.id}`}

View File

@@ -25,6 +25,9 @@ interface CodeProps {
placeholder?: string
language?: 'javascript' | 'json'
generationType?: 'javascript-function-body' | 'json-schema'
value?: string
isPreview?: boolean
previewValue?: string | null
}
if (typeof document !== 'undefined') {
@@ -52,6 +55,9 @@ export function Code({
placeholder = 'Write JavaScript...',
language = 'javascript',
generationType = 'javascript-function-body',
value: propValue,
isPreview = false,
previewValue,
}: CodeProps) {
// Determine the AI prompt placeholder based on language
const aiPromptPlaceholder =
@@ -83,6 +89,8 @@ export function Code({
const toggleCollapsed = () => {
setCollapsedValue(blockId, collapsedStateKey, !isCollapsed)
}
// Use preview value when in preview mode, otherwise use store value or prop value
const value = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue
// AI Code Generation Hook
const handleStreamStart = () => {
@@ -93,14 +101,18 @@ export function Code({
const handleGeneratedContent = (generatedCode: string) => {
setCode(generatedCode)
setStoreValue(generatedCode)
if (!isPreview) {
setStoreValue(generatedCode)
}
}
// Handle streaming chunks directly into the editor
const handleStreamChunk = (chunk: string) => {
setCode((currentCode) => {
const newCode = currentCode + chunk
setStoreValue(newCode)
if (!isPreview) {
setStoreValue(newCode)
}
return newCode
})
}
@@ -126,11 +138,11 @@ export function Code({
// Effects
useEffect(() => {
const valueString = storeValue?.toString() ?? ''
const valueString = value?.toString() ?? ''
if (valueString !== code) {
setCode(valueString)
}
}, [storeValue])
}, [value])
useEffect(() => {
if (!editorRef.current) return
@@ -201,6 +213,7 @@ export function Code({
// Handlers
const handleDrop = (e: React.DragEvent) => {
if (isPreview) return
e.preventDefault()
try {
const data = JSON.parse(e.dataTransfer.getData('application/json'))
@@ -233,8 +246,10 @@ export function Code({
}
const handleTagSelect = (newValue: string) => {
setCode(newValue)
setStoreValue(newValue)
if (!isPreview) {
setCode(newValue)
setStoreValue(newValue)
}
setShowTags(false)
setActiveSourceBlockId(null)
@@ -244,8 +259,10 @@ export function Code({
}
const handleEnvVarSelect = (newValue: string) => {
setCode(newValue)
setStoreValue(newValue)
if (!isPreview) {
setCode(newValue)
setStoreValue(newValue)
}
setShowEnvVars(false)
setTimeout(() => {
@@ -310,7 +327,7 @@ export function Code({
onDrop={handleDrop}
>
<div className='absolute top-2 right-3 z-10 flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100'>
{!isCollapsed && !isAiStreaming && (
{!isCollapsed && !isAiStreaming && !isPreview && (
<Button
variant='ghost'
size='icon'
@@ -323,7 +340,7 @@ export function Code({
</Button>
)}
{showCollapseButton && !isAiStreaming && (
{showCollapseButton && !isAiStreaming && !isPreview && (
<Button
variant='ghost'
size='sm'
@@ -360,7 +377,7 @@ export function Code({
<Editor
value={code}
onValueChange={(newCode) => {
if (!isCollapsed && !isAiStreaming) {
if (!isCollapsed && !isAiStreaming && !isPreview) {
setCode(newCode)
setStoreValue(newCode)

View File

@@ -33,6 +33,8 @@ interface ConditionInputProps {
blockId: string
subBlockId: string
isConnecting: boolean
isPreview?: boolean
previewValue?: string | null
}
// Generate a stable ID based on the blockId and a suffix
@@ -40,7 +42,13 @@ const generateStableId = (blockId: string, suffix: string): string => {
return `${blockId}-${suffix}`
}
export function ConditionInput({ blockId, subBlockId, isConnecting }: ConditionInputProps) {
export function ConditionInput({
blockId,
subBlockId,
isConnecting,
isPreview = false,
previewValue,
}: ConditionInputProps) {
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId)
const editorRef = useRef<HTMLDivElement>(null)
const [visualLineHeights, setVisualLineHeights] = useState<{
@@ -124,15 +132,17 @@ export function ConditionInput({ blockId, subBlockId, isConnecting }: ConditionI
// Skip if syncing is already in progress
if (isSyncingFromStoreRef.current) return
// Convert storeValue to string if it's not null
const storeValueStr = storeValue !== null ? storeValue.toString() : null
// Use preview value when in preview mode, otherwise use store value
const effectiveValue = isPreview ? previewValue : storeValue
// Convert effectiveValue to string if it's not null
const effectiveValueStr = effectiveValue !== null ? effectiveValue?.toString() : null
// Set that we're syncing from store to prevent loops
isSyncingFromStoreRef.current = true
try {
// If store value is null, and we've already initialized, keep current state
if (storeValueStr === null) {
// If effective value is null, and we've already initialized, keep current state
if (effectiveValueStr === null) {
if (hasInitializedRef.current) {
// We already have blocks, just mark as ready if not already
if (!isReady) setIsReady(true)
@@ -148,18 +158,18 @@ export function ConditionInput({ blockId, subBlockId, isConnecting }: ConditionI
return
}
// Skip if the store value hasn't changed and we're already initialized
if (storeValueStr === prevStoreValueRef.current && hasInitializedRef.current) {
// Skip if the effective value hasn't changed and we're already initialized
if (effectiveValueStr === prevStoreValueRef.current && hasInitializedRef.current) {
if (!isReady) setIsReady(true)
isSyncingFromStoreRef.current = false
return
}
// Update the previous store value ref
prevStoreValueRef.current = storeValueStr
prevStoreValueRef.current = effectiveValueStr
// Parse the store value
const parsedBlocks = safeParseJSON(storeValueStr)
// Parse the effective value
const parsedBlocks = safeParseJSON(effectiveValueStr)
if (parsedBlocks) {
// Use the parsed blocks, but ensure titles are correct based on position
@@ -183,13 +193,14 @@ export function ConditionInput({ blockId, subBlockId, isConnecting }: ConditionI
isSyncingFromStoreRef.current = false
}, 0)
}
}, [storeValue, blockId, isReady])
}, [storeValue, previewValue, isPreview, blockId, isReady])
// Update store whenever conditional blocks change
useEffect(() => {
// Skip if we're currently syncing from store to prevent loops
// or if we're not ready yet (still initializing)
if (isSyncingFromStoreRef.current || !isReady || conditionalBlocks.length === 0) return
// or if we're not ready yet (still initializing) or in preview mode
if (isSyncingFromStoreRef.current || !isReady || conditionalBlocks.length === 0 || isPreview)
return
const newValue = JSON.stringify(conditionalBlocks)
@@ -199,7 +210,15 @@ export function ConditionInput({ blockId, subBlockId, isConnecting }: ConditionI
setStoreValue(newValue)
updateNodeInternals(`${blockId}-${subBlockId}`)
}
}, [conditionalBlocks, blockId, subBlockId, setStoreValue, updateNodeInternals, isReady])
}, [
conditionalBlocks,
blockId,
subBlockId,
setStoreValue,
updateNodeInternals,
isReady,
isPreview,
])
// Cleanup when component unmounts
useEffect(() => {
@@ -216,6 +235,8 @@ export function ConditionInput({ blockId, subBlockId, isConnecting }: ConditionI
newValue: string,
textarea: HTMLTextAreaElement | null
) => {
if (isPreview) return
try {
setConditionalBlocks((blocks) =>
blocks.map((block) => {
@@ -343,6 +364,7 @@ export function ConditionInput({ blockId, subBlockId, isConnecting }: ConditionI
// Handle drops from connection blocks - updated for individual blocks
const handleDrop = (blockId: string, e: React.DragEvent) => {
if (isPreview) return
e.preventDefault()
try {
const data = JSON.parse(e.dataTransfer.getData('application/json'))
@@ -384,6 +406,7 @@ export function ConditionInput({ blockId, subBlockId, isConnecting }: ConditionI
// Handle tag selection - updated for individual blocks
const handleTagSelect = (blockId: string, newValue: string) => {
if (isPreview) return
setConditionalBlocks((blocks) =>
blocks.map((block) =>
block.id === blockId
@@ -400,6 +423,7 @@ export function ConditionInput({ blockId, subBlockId, isConnecting }: ConditionI
// Handle environment variable selection - updated for individual blocks
const handleEnvVarSelect = (blockId: string, newValue: string) => {
if (isPreview) return
setConditionalBlocks((blocks) =>
blocks.map((block) =>
block.id === blockId
@@ -424,6 +448,8 @@ export function ConditionInput({ blockId, subBlockId, isConnecting }: ConditionI
// Update these functions to use updateBlockTitles and stable IDs
const addBlock = (afterId: string) => {
if (isPreview) return
const blockIndex = conditionalBlocks.findIndex((block) => block.id === afterId)
// Generate a stable ID using the blockId and a timestamp
@@ -456,6 +482,8 @@ export function ConditionInput({ blockId, subBlockId, isConnecting }: ConditionI
}
const removeBlock = (id: string) => {
if (isPreview) return
// Remove any associated edges before removing the block
edges.forEach((edge) => {
if (edge.sourceHandle?.startsWith(`condition-${id}`)) {
@@ -468,6 +496,8 @@ export function ConditionInput({ blockId, subBlockId, isConnecting }: ConditionI
}
const moveBlock = (id: string, direction: 'up' | 'down') => {
if (isPreview) return
const blockIndex = conditionalBlocks.findIndex((block) => block.id === id)
if (
(direction === 'up' && blockIndex === 0) ||
@@ -509,6 +539,9 @@ export function ConditionInput({ blockId, subBlockId, isConnecting }: ConditionI
})
}, [conditionalBlocks.length])
// Use preview value when in preview mode, otherwise use store value
const value = isPreview ? previewValue : storeValue
// Show loading or empty state if not ready or no blocks
if (!isReady || conditionalBlocks.length === 0) {
return (
@@ -572,6 +605,7 @@ export function ConditionInput({ blockId, subBlockId, isConnecting }: ConditionI
variant='ghost'
size='sm'
onClick={() => addBlock(block.id)}
disabled={isPreview}
className='h-8 w-8'
>
<Plus className='h-4 w-4' />
@@ -588,7 +622,7 @@ export function ConditionInput({ blockId, subBlockId, isConnecting }: ConditionI
variant='ghost'
size='sm'
onClick={() => moveBlock(block.id, 'up')}
disabled={index === 0}
disabled={isPreview || index === 0}
className='h-8 w-8'
>
<ChevronUp className='h-4 w-4' />
@@ -604,7 +638,7 @@ export function ConditionInput({ blockId, subBlockId, isConnecting }: ConditionI
variant='ghost'
size='sm'
onClick={() => moveBlock(block.id, 'down')}
disabled={index === conditionalBlocks.length - 1}
disabled={isPreview || index === conditionalBlocks.length - 1}
className='h-8 w-8'
>
<ChevronDown className='h-4 w-4' />
@@ -621,7 +655,7 @@ export function ConditionInput({ blockId, subBlockId, isConnecting }: ConditionI
variant='ghost'
size='sm'
onClick={() => removeBlock(block.id)}
disabled={conditionalBlocks.length === 1}
disabled={isPreview || conditionalBlocks.length === 1}
className='h-8 w-8 text-destructive hover:text-destructive'
>
<Trash className='h-4 w-4' />
@@ -662,10 +696,12 @@ export function ConditionInput({ blockId, subBlockId, isConnecting }: ConditionI
<Editor
value={block.value}
onValueChange={(newCode) => {
const textarea = editorRef.current?.querySelector(
`[data-block-id="${block.id}"] textarea`
)
updateBlockValue(block.id, newCode, textarea as HTMLTextAreaElement | null)
if (!isPreview) {
const textarea = editorRef.current?.querySelector(
`[data-block-id="${block.id}"] textarea`
)
updateBlockValue(block.id, newCode, textarea as HTMLTextAreaElement | null)
}
}}
onKeyDown={(e) => {
if (e.key === 'Escape') {
@@ -683,8 +719,11 @@ export function ConditionInput({ blockId, subBlockId, isConnecting }: ConditionI
minHeight: '46px',
lineHeight: '21px',
}}
className='focus:outline-none'
textareaClassName='focus:outline-none focus:ring-0 bg-transparent'
className={cn('focus:outline-none', isPreview && 'cursor-not-allowed opacity-50')}
textareaClassName={cn(
'focus:outline-none focus:ring-0 bg-transparent',
isPreview && 'pointer-events-none'
)}
/>
{block.showEnvVars && (

View File

@@ -34,6 +34,8 @@ interface CredentialSelectorProps {
label?: string
disabled?: boolean
serviceId?: string
isPreview?: boolean
previewValue?: any | null
}
export function CredentialSelector({
@@ -44,12 +46,23 @@ export function CredentialSelector({
label = 'Select credential',
disabled = false,
serviceId,
isPreview = false,
previewValue,
}: CredentialSelectorProps) {
const [open, setOpen] = useState(false)
const [credentials, setCredentials] = useState<Credential[]>([])
const [isLoading, setIsLoading] = useState(false)
const [showOAuthModal, setShowOAuthModal] = useState(false)
const [selectedId, setSelectedId] = useState(value)
const [selectedId, setSelectedId] = useState('')
// Initialize selectedId with the effective value
useEffect(() => {
if (isPreview && previewValue !== undefined) {
setSelectedId(previewValue || '')
} else {
setSelectedId(value)
}
}, [value, isPreview, previewValue])
// Derive service and provider IDs using useMemo
const effectiveServiceId = useMemo(() => {
@@ -110,8 +123,9 @@ export function CredentialSelector({
// Update local state when external value changes
useEffect(() => {
setSelectedId(value)
}, [value])
const currentValue = isPreview ? previewValue : value
setSelectedId(currentValue || '')
}, [value, isPreview, previewValue])
// Listen for visibility changes to update credentials when user returns from settings
useEffect(() => {
@@ -143,7 +157,9 @@ export function CredentialSelector({
// Handle selection
const handleSelect = (credentialId: string) => {
setSelectedId(credentialId)
onChange(credentialId)
if (!isPreview) {
onChange(credentialId)
}
setOpen(false)
}

View File

@@ -14,10 +14,21 @@ interface DateInputProps {
blockId: string
subBlockId: string
placeholder?: string
isPreview?: boolean
previewValue?: string | null
}
export function DateInput({ blockId, subBlockId, placeholder }: DateInputProps) {
const [value, setValue] = useSubBlockValue<string>(blockId, subBlockId, true)
export function DateInput({
blockId,
subBlockId,
placeholder,
isPreview = false,
previewValue,
}: DateInputProps) {
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId)
// Use preview value when in preview mode, otherwise use store value
const value = isPreview ? previewValue : storeValue
const addNotification = useNotificationStore((state) => state.addNotification)
const date = value ? new Date(value) : undefined
@@ -29,6 +40,8 @@ export function DateInput({ blockId, subBlockId, placeholder }: DateInputProps)
}, [date])
const handleDateSelect = (selectedDate: Date | undefined) => {
if (isPreview) return
if (selectedDate) {
const today = new Date()
today.setHours(0, 0, 0, 0)
@@ -37,7 +50,7 @@ export function DateInput({ blockId, subBlockId, placeholder }: DateInputProps)
addNotification('error', 'Cannot start at a date in the past', blockId)
}
}
setValue(selectedDate?.toISOString() || '')
setStoreValue(selectedDate?.toISOString() || '')
}
return (
@@ -45,6 +58,7 @@ export function DateInput({ blockId, subBlockId, placeholder }: DateInputProps)
<PopoverTrigger asChild>
<Button
variant='outline'
disabled={isPreview}
className={cn(
'w-full justify-start text-left font-normal',
!date && 'text-muted-foreground',

View File

@@ -15,12 +15,26 @@ interface DropdownProps {
defaultValue?: string
blockId: string
subBlockId: string
value?: string
isPreview?: boolean
previewValue?: string | null
}
export function Dropdown({ options, defaultValue, blockId, subBlockId }: DropdownProps) {
const [value, setValue] = useSubBlockValue<string>(blockId, subBlockId, true)
export function Dropdown({
options,
defaultValue,
blockId,
subBlockId,
value: propValue,
isPreview = false,
previewValue,
}: DropdownProps) {
const [storeValue, setStoreValue] = useSubBlockValue<string>(blockId, subBlockId)
const [storeInitialized, setStoreInitialized] = useState(false)
// Use preview value when in preview mode, otherwise use store value or prop value
const value = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue
// Evaluate options if it's a function
const evaluatedOptions = useMemo(() => {
return typeof options === 'function' ? options() : options
@@ -60,9 +74,9 @@ export function Dropdown({ options, defaultValue, blockId, subBlockId }: Dropdow
(value === null || value === undefined) &&
defaultOptionValue !== undefined
) {
setValue(defaultOptionValue)
setStoreValue(defaultOptionValue)
}
}, [storeInitialized, value, defaultOptionValue, setValue])
}, [storeInitialized, value, defaultOptionValue, setStoreValue])
// Calculate the effective value to use in the dropdown
const effectiveValue = useMemo(() => {
@@ -89,7 +103,13 @@ export function Dropdown({ options, defaultValue, blockId, subBlockId }: Dropdow
return (
<Select
value={isValueInOptions ? effectiveValue : undefined}
onValueChange={(newValue) => setValue(newValue)}
onValueChange={(newValue) => {
// Only update store when not in preview mode
if (!isPreview) {
setStoreValue(newValue)
}
}}
disabled={isPreview}
>
<SelectTrigger className='text-left'>
<SelectValue placeholder='Select an option' />

View File

@@ -18,6 +18,8 @@ interface EvalMetric {
interface EvalInputProps {
blockId: string
subBlockId: string
isPreview?: boolean
previewValue?: EvalMetric[] | null
}
// Default values
@@ -28,37 +30,55 @@ const DEFAULT_METRIC: EvalMetric = {
range: { min: 0, max: 1 },
}
export function EvalInput({ blockId, subBlockId }: EvalInputProps) {
export function EvalInput({
blockId,
subBlockId,
isPreview = false,
previewValue,
}: EvalInputProps) {
const [storeValue, setStoreValue] = useSubBlockValue<EvalMetric[]>(blockId, subBlockId)
// Use preview value when in preview mode, otherwise use store value
const value = isPreview ? previewValue : storeValue
// State hooks
const [value, setValue] = useSubBlockValue<EvalMetric[]>(blockId, subBlockId)
const metrics = value || [DEFAULT_METRIC]
const metrics: EvalMetric[] = value || [DEFAULT_METRIC]
// Metric operations
const addMetric = () => {
if (isPreview) return
const newMetric: EvalMetric = {
...DEFAULT_METRIC,
id: crypto.randomUUID(),
}
setValue([...metrics, newMetric])
setStoreValue([...metrics, newMetric])
}
const removeMetric = (id: string) => {
if (metrics.length === 1) return
setValue(metrics.filter((metric) => metric.id !== id))
if (isPreview || metrics.length === 1) return
setStoreValue(metrics.filter((metric) => metric.id !== id))
}
// Update handlers
const updateMetric = (id: string, field: keyof EvalMetric, value: any) => {
setValue(metrics.map((metric) => (metric.id === id ? { ...metric, [field]: value } : metric)))
if (isPreview) return
setStoreValue(
metrics.map((metric) => (metric.id === id ? { ...metric, [field]: value } : metric))
)
}
const updateRange = (id: string, field: 'min' | 'max', value: string) => {
setValue(
if (isPreview) return
setStoreValue(
metrics.map((metric) =>
metric.id === id
? {
...metric,
range: { ...metric.range, [field]: value },
range: {
...metric.range,
[field]: value === '' ? undefined : Number.parseInt(value, 10),
},
}
: metric
)
@@ -70,7 +90,7 @@ export function EvalInput({ blockId, subBlockId }: EvalInputProps) {
const sanitizedValue = value.replace(/[^\d.-]/g, '')
const numValue = Number.parseFloat(sanitizedValue)
setValue(
setStoreValue(
metrics.map((metric) =>
metric.id === id
? {
@@ -92,7 +112,13 @@ export function EvalInput({ blockId, subBlockId }: EvalInputProps) {
<div className='flex items-center gap-1'>
<Tooltip>
<TooltipTrigger asChild>
<Button variant='ghost' size='sm' onClick={addMetric} className='h-8 w-8'>
<Button
variant='ghost'
size='sm'
onClick={addMetric}
disabled={isPreview}
className='h-8 w-8'
>
<Plus className='h-4 w-4' />
<span className='sr-only'>Add Metric</span>
</Button>
@@ -106,7 +132,7 @@ export function EvalInput({ blockId, subBlockId }: EvalInputProps) {
variant='ghost'
size='sm'
onClick={() => removeMetric(metric.id)}
disabled={metrics.length === 1}
disabled={isPreview || metrics.length === 1}
className='h-8 w-8 text-destructive hover:text-destructive'
>
<Trash className='h-4 w-4' />
@@ -138,6 +164,7 @@ export function EvalInput({ blockId, subBlockId }: EvalInputProps) {
value={metric.name}
onChange={(e) => updateMetric(metric.id, 'name', e.target.value)}
placeholder='Accuracy'
disabled={isPreview}
className='placeholder:text-muted-foreground/50'
/>
</div>
@@ -148,6 +175,7 @@ export function EvalInput({ blockId, subBlockId }: EvalInputProps) {
value={metric.description}
onChange={(e) => updateMetric(metric.id, 'description', e.target.value)}
placeholder='How accurate is the response?'
disabled={isPreview}
className='placeholder:text-muted-foreground/50'
/>
</div>
@@ -160,6 +188,7 @@ export function EvalInput({ blockId, subBlockId }: EvalInputProps) {
value={metric.range.min}
onChange={(e) => updateRange(metric.id, 'min', e.target.value)}
onBlur={(e) => handleRangeBlur(metric.id, 'min', e.target.value)}
disabled={isPreview}
className='placeholder:text-muted-foreground/50'
/>
</div>
@@ -170,6 +199,7 @@ export function EvalInput({ blockId, subBlockId }: EvalInputProps) {
value={metric.range.max}
onChange={(e) => updateRange(metric.id, 'max', e.target.value)}
onBlur={(e) => handleRangeBlur(metric.id, 'max', e.target.value)}
disabled={isPreview}
className='placeholder:text-muted-foreground/50'
/>
</div>

View File

@@ -22,10 +22,18 @@ import { TeamsMessageSelector } from './components/teams-message-selector'
interface FileSelectorInputProps {
blockId: string
subBlock: SubBlockConfig
disabled?: boolean
disabled: boolean
isPreview?: boolean
previewValue?: any | null
}
export function FileSelectorInput({ blockId, subBlock, disabled = false }: FileSelectorInputProps) {
export function FileSelectorInput({
blockId,
subBlock,
disabled,
isPreview = false,
previewValue,
}: FileSelectorInputProps) {
const { getValue, setValue } = useSubBlockStore()
const { activeWorkflowId } = useWorkflowRegistry()
const [selectedFileId, setSelectedFileId] = useState<string>('')
@@ -50,21 +58,39 @@ export function FileSelectorInput({ blockId, subBlock, disabled = false }: FileS
const botToken = isDiscord ? (getValue(blockId, 'botToken') as string) || '' : ''
const serverId = isDiscord ? (getValue(blockId, 'serverId') as string) || '' : ''
// Get the current value from the store
// Use preview value when in preview mode, otherwise use store value
const value = isPreview ? previewValue : getValue(blockId, subBlock.id)
// Get the current value from the store or prop value if in preview mode
useEffect(() => {
const value = getValue(blockId, subBlock.id)
if (value && typeof value === 'string') {
if (isJira) {
setSelectedIssueId(value)
} else if (isDiscord) {
setSelectedChannelId(value)
} else if (isMicrosoftTeams) {
setSelectedMessageId(value)
} else {
setSelectedFileId(value)
if (isPreview && previewValue !== undefined) {
const value = previewValue
if (value && typeof value === 'string') {
if (isJira) {
setSelectedIssueId(value)
} else if (isDiscord) {
setSelectedChannelId(value)
} else if (isMicrosoftTeams) {
setSelectedMessageId(value)
} else {
setSelectedFileId(value)
}
}
} else {
const value = getValue(blockId, subBlock.id)
if (value && typeof value === 'string') {
if (isJira) {
setSelectedIssueId(value)
} else if (isDiscord) {
setSelectedChannelId(value)
} else if (isMicrosoftTeams) {
setSelectedMessageId(value)
} else {
setSelectedFileId(value)
}
}
}
}, [blockId, subBlock.id, getValue, isJira, isDiscord, isMicrosoftTeams])
}, [blockId, subBlock.id, getValue, isJira, isDiscord, isMicrosoftTeams, isPreview, previewValue])
// Handle file selection
const handleFileChange = (fileId: string, info?: any) => {

View File

@@ -16,6 +16,8 @@ interface FileUploadProps {
maxSize?: number // in MB
acceptedTypes?: string // comma separated MIME types
multiple?: boolean // whether to allow multiple file uploads
isPreview?: boolean
previewValue?: any | null
}
interface UploadedFile {
@@ -37,13 +39,11 @@ export function FileUpload({
maxSize = 10, // Default 10MB
acceptedTypes = '*',
multiple = false, // Default to single file for backward compatibility
isPreview = false,
previewValue,
}: FileUploadProps) {
// State management - handle both single file and array of files
const [value, setValue] = useSubBlockValue<UploadedFile | UploadedFile[] | null>(
blockId,
subBlockId,
true
)
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId)
const [uploadingFiles, setUploadingFiles] = useState<UploadingFile[]>([])
const [uploadProgress, setUploadProgress] = useState(0)
@@ -57,6 +57,9 @@ export function FileUpload({
const { addNotification } = useNotificationStore()
const { activeWorkflowId } = useWorkflowRegistry()
// Use preview value when in preview mode, otherwise use store value
const value = isPreview ? previewValue : storeValue
/**
* Opens file dialog
* Prevents event propagation to avoid ReactFlow capturing the event
@@ -84,6 +87,8 @@ export function FileUpload({
* Handles file upload when new file(s) are selected
*/
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (isPreview) return
e.stopPropagation()
const files = e.target.files
@@ -280,14 +285,14 @@ export function FileUpload({
// Convert map values back to array
const newFiles = Array.from(uniqueFiles.values())
setValue(newFiles)
setStoreValue(newFiles)
// Make sure to update the subblock store value for the workflow execution
useSubBlockStore.getState().setValue(blockId, subBlockId, newFiles)
useWorkflowStore.getState().triggerUpdate()
} else {
// For single file: Replace with last uploaded file
setValue(uploadedFiles[0] || null)
setStoreValue(uploadedFiles[0] || null)
// Make sure to update the subblock store value for the workflow execution
useSubBlockStore.getState().setValue(blockId, subBlockId, uploadedFiles[0] || null)
@@ -345,7 +350,7 @@ export function FileUpload({
// For multiple files: Remove the specific file
const filesArray = Array.isArray(value) ? value : value ? [value] : []
const updatedFiles = filesArray.filter((f) => f.path !== file.path)
setValue(updatedFiles.length > 0 ? updatedFiles : null)
setStoreValue(updatedFiles.length > 0 ? updatedFiles : null)
// Make sure to update the subblock store value for the workflow execution
useSubBlockStore
@@ -353,7 +358,7 @@ export function FileUpload({
.setValue(blockId, subBlockId, updatedFiles.length > 0 ? updatedFiles : null)
} else {
// For single file: Clear the value
setValue(null)
setStoreValue(null)
// Make sure to update the subblock store
useSubBlockStore.getState().setValue(blockId, subBlockId, null)
@@ -396,7 +401,7 @@ export function FileUpload({
setDeletingFiles(deletingStatus)
// Clear input state immediately for better UX
setValue(null)
setStoreValue(null)
useSubBlockStore.getState().setValue(blockId, subBlockId, null)
useWorkflowStore.getState().triggerUpdate()

View File

@@ -9,34 +9,46 @@ interface FolderSelectorInputProps {
blockId: string
subBlock: SubBlockConfig
disabled?: boolean
isPreview?: boolean
previewValue?: any | null
}
export function FolderSelectorInput({
blockId,
subBlock,
disabled = false,
isPreview = false,
previewValue,
}: FolderSelectorInputProps) {
const { getValue, setValue } = useSubBlockStore()
const [selectedFolderId, setSelectedFolderId] = useState<string>('')
const [_folderInfo, setFolderInfo] = useState<FolderInfo | null>(null)
// Get the current value from the store
// Get the current value from the store or prop value if in preview mode
useEffect(() => {
const value = getValue(blockId, subBlock.id)
if (value && typeof value === 'string') {
setSelectedFolderId(value)
if (isPreview && previewValue !== undefined) {
setSelectedFolderId(previewValue)
} else {
const defaultValue = 'INBOX'
setSelectedFolderId(defaultValue)
setValue(blockId, subBlock.id, defaultValue)
const value = getValue(blockId, subBlock.id)
if (value && typeof value === 'string') {
setSelectedFolderId(value)
} else {
const defaultValue = 'INBOX'
setSelectedFolderId(defaultValue)
if (!isPreview) {
setValue(blockId, subBlock.id, defaultValue)
}
}
}
}, [blockId, subBlock.id, getValue, setValue])
}, [blockId, subBlock.id, getValue, setValue, isPreview, previewValue])
// Handle folder selection
const handleFolderChange = (folderId: string, info?: FolderInfo) => {
setSelectedFolderId(folderId)
setFolderInfo(info || null)
setValue(blockId, subBlock.id, folderId)
if (!isPreview) {
setValue(blockId, subBlock.id, folderId)
}
}
return (

View File

@@ -37,6 +37,8 @@ interface FolderSelectorProps {
disabled?: boolean
serviceId?: string
onFolderInfoChange?: (folderInfo: FolderInfo | null) => void
isPreview?: boolean
previewValue?: any | null
}
export function FolderSelector({
@@ -48,18 +50,29 @@ export function FolderSelector({
disabled = false,
serviceId,
onFolderInfoChange,
isPreview = false,
previewValue,
}: FolderSelectorProps) {
const [open, setOpen] = useState(false)
const [credentials, setCredentials] = useState<Credential[]>([])
const [folders, setFolders] = useState<FolderInfo[]>([])
const [selectedCredentialId, setSelectedCredentialId] = useState<string>('')
const [selectedFolderId, setSelectedFolderId] = useState(value)
const [selectedFolderId, setSelectedFolderId] = useState('')
const [selectedFolder, setSelectedFolder] = useState<FolderInfo | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [isLoadingSelectedFolder, setIsLoadingSelectedFolder] = useState(false)
const [showOAuthModal, setShowOAuthModal] = useState(false)
const initialFetchRef = useRef(false)
// Initialize selectedFolderId with the effective value
useEffect(() => {
if (isPreview && previewValue !== undefined) {
setSelectedFolderId(previewValue || '')
} else {
setSelectedFolderId(value)
}
}, [value, isPreview, previewValue])
// Determine the appropriate service ID based on provider and scopes
const getServiceId = (): string => {
if (serviceId) return serviceId
@@ -226,23 +239,35 @@ export function FolderSelector({
// Keep internal selectedFolderId in sync with the value prop
useEffect(() => {
if (value !== selectedFolderId) {
setSelectedFolderId(value)
const currentValue = isPreview ? previewValue : value
if (currentValue !== selectedFolderId) {
setSelectedFolderId(currentValue || '')
}
}, [value])
}, [value, isPreview, previewValue])
// Fetch the selected folder metadata once credentials are ready (Gmail only)
useEffect(() => {
if (value && selectedCredentialId && !selectedFolder && provider !== 'outlook') {
fetchFolderById(value)
const currentValue = isPreview ? previewValue : value
if (currentValue && selectedCredentialId && !selectedFolder && provider !== 'outlook') {
fetchFolderById(currentValue)
}
}, [value, selectedCredentialId, selectedFolder, fetchFolderById, provider])
}, [
value,
selectedCredentialId,
selectedFolder,
fetchFolderById,
provider,
isPreview,
previewValue,
])
// Handle folder selection
const handleSelectFolder = (folder: FolderInfo) => {
setSelectedFolderId(folder.id)
setSelectedFolder(folder)
onChange(folder.id, folder)
if (!isPreview) {
onChange(folder.id, folder)
}
onFolderInfoChange?.(folder)
setOpen(false)
}

View File

@@ -19,6 +19,10 @@ interface LongInputProps {
isConnecting: boolean
config: SubBlockConfig
rows?: number
isPreview?: boolean
previewValue?: string | null
value?: string
onChange?: (value: string) => void
}
// Constants
@@ -33,8 +37,12 @@ export function LongInput({
isConnecting,
config,
rows,
isPreview = false,
previewValue,
value: propValue,
onChange,
}: LongInputProps) {
const [value, setValue] = useSubBlockValue(blockId, subBlockId)
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId)
const [showEnvVars, setShowEnvVars] = useState(false)
const [showTags, setShowTags] = useState(false)
const [searchTerm, setSearchTerm] = useState('')
@@ -44,6 +52,9 @@ export function LongInput({
const [activeSourceBlockId, setActiveSourceBlockId] = useState<string | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
// Use preview value when in preview mode, otherwise use store value or prop value
const value = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue
// Calculate initial height based on rows prop with reasonable defaults
const getInitialHeight = () => {
// Use provided rows or default, then convert to pixels with a minimum
@@ -72,7 +83,14 @@ export function LongInput({
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value
const newCursorPosition = e.target.selectionStart ?? 0
setValue(newValue)
if (onChange) {
onChange(newValue)
} else if (!isPreview) {
// Only update store when not in preview mode
setStoreValue(newValue)
}
setCursorPosition(newCursorPosition)
// Check for environment variables trigger
@@ -167,7 +185,9 @@ export function LongInput({
// Update all state in a single batch
Promise.resolve().then(() => {
setValue(newValue)
if (!isPreview) {
setStoreValue(newValue)
}
setCursorPosition(dropPosition + 1)
setShowTags(true)
@@ -268,6 +288,7 @@ export function LongInput({
setShowTags(false)
setSearchTerm('')
}}
disabled={isPreview}
style={{
fontFamily: 'inherit',
lineHeight: 'inherit',
@@ -300,7 +321,13 @@ export function LongInput({
<EnvVarDropdown
visible={showEnvVars}
onSelect={setValue}
onSelect={(newValue) => {
if (onChange) {
onChange(newValue)
} else if (!isPreview) {
setStoreValue(newValue)
}
}}
searchTerm={searchTerm}
inputValue={value?.toString() ?? ''}
cursorPosition={cursorPosition}
@@ -311,7 +338,13 @@ export function LongInput({
/>
<TagDropdown
visible={showTags}
onSelect={setValue}
onSelect={(newValue) => {
if (onChange) {
onChange(newValue)
} else if (!isPreview) {
setStoreValue(newValue)
}
}}
blockId={blockId}
activeSourceBlockId={activeSourceBlockId}
inputValue={value?.toString() ?? ''}

View File

@@ -14,6 +14,8 @@ interface ProjectSelectorInputProps {
subBlock: SubBlockConfig
disabled?: boolean
onProjectSelect?: (projectId: string) => void
isPreview?: boolean
previewValue?: any | null
}
export function ProjectSelectorInput({
@@ -21,6 +23,8 @@ export function ProjectSelectorInput({
subBlock,
disabled = false,
onProjectSelect,
isPreview = false,
previewValue,
}: ProjectSelectorInputProps) {
const { getValue, setValue } = useSubBlockStore()
const [selectedProjectId, setSelectedProjectId] = useState<string>('')
@@ -35,13 +39,17 @@ export function ProjectSelectorInput({
const domain = !isDiscord ? (getValue(blockId, 'domain') as string) || '' : ''
const botToken = isDiscord ? (getValue(blockId, 'botToken') as string) || '' : ''
// Get the current value from the store
// Get the current value from the store or prop value if in preview mode
useEffect(() => {
const value = getValue(blockId, subBlock.id)
if (value && typeof value === 'string') {
setSelectedProjectId(value)
if (isPreview && previewValue !== undefined) {
setSelectedProjectId(previewValue)
} else {
const value = getValue(blockId, subBlock.id)
if (value && typeof value === 'string') {
setSelectedProjectId(value)
}
}
}, [blockId, subBlock.id, getValue])
}, [blockId, subBlock.id, getValue, isPreview, previewValue])
// Handle project selection
const handleProjectChange = (

View File

@@ -17,11 +17,19 @@ const logger = createLogger('ScheduleConfig')
interface ScheduleConfigProps {
blockId: string
subBlockId?: string
subBlockId: string
isConnecting: boolean
isPreview?: boolean
previewValue?: any | null
}
export function ScheduleConfig({ blockId, subBlockId, isConnecting }: ScheduleConfigProps) {
export function ScheduleConfig({
blockId,
subBlockId,
isConnecting,
isPreview = false,
previewValue,
}: ScheduleConfigProps) {
const [error, setError] = useState<string | null>(null)
const [scheduleId, setScheduleId] = useState<string | null>(null)
const [nextRunAt, setNextRunAt] = useState<string | null>(null)
@@ -47,7 +55,12 @@ export function ScheduleConfig({ blockId, subBlockId, isConnecting }: ScheduleCo
// Get the startWorkflow value to determine if scheduling is enabled
// and expose the setter so we can update it
const [startWorkflow, setStartWorkflow] = useSubBlockValue(blockId, 'startWorkflow')
const _isScheduleEnabled = startWorkflow === 'schedule'
const isScheduleEnabled = startWorkflow === 'schedule'
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId)
// Use preview value when in preview mode, otherwise use store value
const value = isPreview ? previewValue : storeValue
// Function to check if schedule exists in the database
const checkSchedule = async () => {
@@ -124,6 +137,7 @@ export function ScheduleConfig({ blockId, subBlockId, isConnecting }: ScheduleCo
}
const handleOpenModal = () => {
if (isPreview) return
setIsModalOpen(true)
}
@@ -137,6 +151,8 @@ export function ScheduleConfig({ blockId, subBlockId, isConnecting }: ScheduleCo
}
const handleSaveSchedule = async (): Promise<boolean> => {
if (isPreview) return false
setIsSaving(true)
setError(null)
@@ -239,7 +255,7 @@ export function ScheduleConfig({ blockId, subBlockId, isConnecting }: ScheduleCo
}
const handleDeleteSchedule = async (): Promise<boolean> => {
if (!scheduleId) return false
if (isPreview || !scheduleId) return false
setIsDeleting(true)
try {
@@ -312,7 +328,7 @@ export function ScheduleConfig({ blockId, subBlockId, isConnecting }: ScheduleCo
size='icon'
className='h-8 w-8 shrink-0'
onClick={handleOpenModal}
disabled={isDeleting || isConnecting}
disabled={isPreview || isDeleting || isConnecting}
>
{isDeleting ? (
<div className='h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
@@ -328,7 +344,7 @@ export function ScheduleConfig({ blockId, subBlockId, isConnecting }: ScheduleCo
size='sm'
className='flex h-10 w-full items-center bg-background font-normal text-sm'
onClick={handleOpenModal}
disabled={isConnecting || isSaving || isDeleting}
disabled={isPreview || isConnecting || isSaving || isDeleting}
>
{isLoading ? (
<div className='mr-2 h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />

View File

@@ -20,6 +20,8 @@ interface ShortInputProps {
config: SubBlockConfig
value?: string
onChange?: (value: string) => void
isPreview?: boolean
previewValue?: string | null
}
export function ShortInput({
@@ -29,12 +31,23 @@ export function ShortInput({
password,
isConnecting,
config,
value: propValue,
onChange,
value: propValue,
isPreview = false,
previewValue,
}: ShortInputProps) {
const [isFocused, setIsFocused] = useState(false)
const [showEnvVars, setShowEnvVars] = useState(false)
const [showTags, setShowTags] = useState(false)
const validatePropValue = (value: any): string => {
if (value === undefined || value === null) return ''
if (typeof value === 'string') return value
try {
return String(value)
} catch {
return ''
}
}
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId)
const [searchTerm, setSearchTerm] = useState('')
const [cursorPosition, setCursorPosition] = useState(0)
@@ -45,8 +58,8 @@ export function ShortInput({
// Get ReactFlow instance for zoom control
const reactFlowInstance = useReactFlow()
// Use either controlled or uncontrolled value
const value = propValue !== undefined ? propValue : storeValue
// Use preview value when in preview mode, otherwise use store value or prop value
const value = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue
// Check if this input is API key related
const isApiKeyField = useMemo(() => {
@@ -84,7 +97,8 @@ export function ShortInput({
if (onChange) {
onChange(newValue)
} else {
} else if (!isPreview) {
// Only update store when not in preview mode
setStoreValue(newValue)
}
@@ -265,7 +279,8 @@ export function ShortInput({
if (onChange) {
onChange(newValue)
} else {
} else if (!isPreview) {
// Only update store when not in preview mode
setStoreValue(newValue)
}
}
@@ -313,6 +328,7 @@ export function ShortInput({
onKeyDown={handleKeyDown}
autoComplete='off'
style={{ overflowX: 'auto' }}
disabled={isPreview}
/>
<div
ref={overlayRef}

View File

@@ -1,50 +1,54 @@
import { useEffect, useMemo } from 'react'
import { useEffect } from 'react'
import { Slider } from '@/components/ui/slider'
import { useSubBlockValue } from '../hooks/use-sub-block-value'
interface SliderInputProps {
min?: number
max?: number
defaultValue: number
blockId: string
subBlockId: string
min?: number
max?: number
defaultValue?: number
step?: number
integer?: boolean
isPreview?: boolean
previewValue?: number | null
}
export function SliderInput({
min = 0,
max = 100,
defaultValue,
blockId,
subBlockId,
min = 0,
max = 100,
defaultValue = 50,
step = 0.1,
integer = false,
isPreview = false,
previewValue,
}: SliderInputProps) {
const [value, setValue] = useSubBlockValue<number>(blockId, subBlockId)
const [storeValue, setStoreValue] = useSubBlockValue<number>(blockId, subBlockId)
// Use preview value when in preview mode, otherwise use store value
const value = isPreview ? previewValue : storeValue
// Clamp the value within bounds while preserving relative position when possible
const normalizedValue = useMemo(() => {
if (value === null) return defaultValue
const normalizedValue =
value !== null && value !== undefined ? Math.max(min, Math.min(max, value)) : defaultValue
// If value exceeds max, scale it down proportionally
if (value > max) {
const prevMax = Math.max(max * 2, value) // Assume previous max was at least the current value
const scaledValue = (value / prevMax) * max
return integer ? Math.round(scaledValue) : scaledValue
}
const displayValue = normalizedValue ?? defaultValue
// Otherwise just clamp it
const clampedValue = Math.min(Math.max(value, min), max)
return integer ? Math.round(clampedValue) : clampedValue
}, [value, min, max, defaultValue, integer])
// Update the value if it needs normalization
// Ensure the normalized value is set if it differs from the current value
useEffect(() => {
if (value !== null && value !== normalizedValue) {
setValue(normalizedValue)
if (!isPreview && value !== null && value !== undefined && value !== normalizedValue) {
setStoreValue(normalizedValue)
}
}, [normalizedValue, value, setValue])
}, [normalizedValue, value, setStoreValue, isPreview])
const handleValueChange = (newValue: number[]) => {
if (!isPreview) {
const processedValue = integer ? Math.round(newValue[0]) : newValue[0]
setStoreValue(processedValue)
}
}
return (
<div className='relative pt-2 pb-6'>
@@ -53,7 +57,8 @@ export function SliderInput({
min={min}
max={max}
step={integer ? 1 : step}
onValueChange={(value) => setValue(integer ? Math.round(value[0]) : value[0])}
onValueChange={(value) => setStoreValue(integer ? Math.round(value[0]) : value[0])}
disabled={isPreview}
className='[&_[class*=SliderTrack]]:h-1 [&_[role=slider]]:h-4 [&_[role=slider]]:w-4'
/>
<div

View File

@@ -22,6 +22,8 @@ interface InputField {
interface InputFormatProps {
blockId: string
subBlockId: string
isPreview?: boolean
previewValue?: InputField[] | null
}
// Default values
@@ -32,32 +34,45 @@ const DEFAULT_FIELD: InputField = {
collapsed: true,
}
export function InputFormat({ blockId, subBlockId }: InputFormatProps) {
// State hooks
const [value, setValue] = useSubBlockValue<InputField[]>(blockId, subBlockId)
const fields = value || [DEFAULT_FIELD]
export function InputFormat({
blockId,
subBlockId,
isPreview = false,
previewValue,
}: InputFormatProps) {
const [storeValue, setStoreValue] = useSubBlockValue<InputField[]>(blockId, subBlockId)
// Use preview value when in preview mode, otherwise use store value
const value = isPreview ? previewValue : storeValue
const fields: InputField[] = value || [DEFAULT_FIELD]
// Field operations
const addField = () => {
if (isPreview) return
const newField: InputField = {
...DEFAULT_FIELD,
id: crypto.randomUUID(),
}
setValue([...fields, newField])
setStoreValue([...fields, newField])
}
const removeField = (id: string) => {
if (fields.length === 1) return
setValue(fields.filter((field) => field.id !== id))
if (isPreview || fields.length === 1) return
setStoreValue(fields.filter((field: InputField) => field.id !== id))
}
// Update handlers
const updateField = (id: string, field: keyof InputField, value: any) => {
setValue(fields.map((f) => (f.id === id ? { ...f, [field]: value } : f)))
if (isPreview) return
setStoreValue(fields.map((f: InputField) => (f.id === id ? { ...f, [field]: value } : f)))
}
const toggleCollapse = (id: string) => {
setValue(fields.map((f) => (f.id === id ? { ...f, collapsed: !f.collapsed } : f)))
if (isPreview) return
setStoreValue(
fields.map((f: InputField) => (f.id === id ? { ...f, collapsed: !f.collapsed } : f))
)
}
// Field header
@@ -85,7 +100,13 @@ export function InputFormat({ blockId, subBlockId }: InputFormatProps) {
)}
</div>
<div className='flex items-center gap-1' onClick={(e) => e.stopPropagation()}>
<Button variant='ghost' size='icon' onClick={addField} className='h-6 w-6 rounded-full'>
<Button
variant='ghost'
size='icon'
onClick={addField}
disabled={isPreview}
className='h-6 w-6 rounded-full'
>
<Plus className='h-3.5 w-3.5' />
<span className='sr-only'>Add Field</span>
</Button>
@@ -94,7 +115,7 @@ export function InputFormat({ blockId, subBlockId }: InputFormatProps) {
variant='ghost'
size='icon'
onClick={() => removeField(field.id)}
disabled={fields.length === 1}
disabled={isPreview || fields.length === 1}
className='h-6 w-6 rounded-full text-destructive hover:text-destructive'
>
<Trash className='h-3.5 w-3.5' />
@@ -135,6 +156,7 @@ export function InputFormat({ blockId, subBlockId }: InputFormatProps) {
value={field.name}
onChange={(e) => updateField(field.id, 'name', e.target.value)}
placeholder='firstName'
disabled={isPreview}
className='h-9 placeholder:text-muted-foreground/50'
/>
</div>
@@ -143,7 +165,11 @@ export function InputFormat({ blockId, subBlockId }: InputFormatProps) {
<Label className='text-xs'>Type</Label>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='outline' className='h-9 w-full justify-between font-normal'>
<Button
variant='outline'
disabled={isPreview}
className='h-9 w-full justify-between font-normal'
>
<div className='flex items-center'>
<span>{field.type}</span>
</div>

View File

@@ -6,17 +6,45 @@ interface SwitchProps {
blockId: string
subBlockId: string
title: string
value?: boolean
isPreview?: boolean
previewValue?: boolean | null
}
export function Switch({ blockId, subBlockId, title }: SwitchProps) {
const [value, setValue] = useSubBlockValue(blockId, subBlockId)
export function Switch({
blockId,
subBlockId,
title,
value: propValue,
isPreview = false,
previewValue,
}: SwitchProps) {
const [storeValue, setStoreValue] = useSubBlockValue<boolean>(blockId, subBlockId)
// Use preview value when in preview mode, otherwise use store value or prop value
const value = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue
const handleChange = (checked: boolean) => {
// Only update store when not in preview mode
if (!isPreview) {
setStoreValue(checked)
}
}
return (
<div className='flex flex-col gap-2'>
<Label className='font-medium text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'>
<div className='flex items-center space-x-3'>
<UISwitch
id={`${blockId}-${subBlockId}`}
checked={Boolean(value)}
onCheckedChange={handleChange}
disabled={isPreview}
/>
<Label
htmlFor={`${blockId}-${subBlockId}`}
className='cursor-pointer font-normal text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
>
{title}
</Label>
<UISwitch checked={Boolean(value)} onCheckedChange={(checked) => setValue(checked)} />
</div>
)
}

View File

@@ -9,9 +9,11 @@ import { cn } from '@/lib/utils'
import { useSubBlockValue } from '../hooks/use-sub-block-value'
interface TableProps {
columns: string[]
blockId: string
subBlockId: string
columns: string[]
isPreview?: boolean
previewValue?: TableRow[] | null
}
interface TableRow {
@@ -19,8 +21,17 @@ interface TableRow {
cells: Record<string, string>
}
export function Table({ columns, blockId, subBlockId }: TableProps) {
const [value, setValue] = useSubBlockValue(blockId, subBlockId)
export function Table({
blockId,
subBlockId,
columns,
isPreview = false,
previewValue,
}: TableProps) {
const [storeValue, setStoreValue] = useSubBlockValue<TableRow[]>(blockId, subBlockId)
// Use preview value when in preview mode, otherwise use store value
const value = isPreview ? previewValue : storeValue
// Create refs for input elements
const inputRefs = useRef<Map<string, HTMLInputElement>>(new Map())
@@ -71,6 +82,8 @@ export function Table({ columns, blockId, subBlockId }: TableProps) {
}, [activeCell])
const handleCellChange = (rowIndex: number, column: string, value: string) => {
if (isPreview) return
const updatedRows = [...rows].map((row, idx) =>
idx === rowIndex
? {
@@ -87,12 +100,12 @@ export function Table({ columns, blockId, subBlockId }: TableProps) {
})
}
setValue(updatedRows)
setStoreValue(updatedRows)
}
const handleDeleteRow = (rowIndex: number) => {
if (rows.length === 1) return
setValue(rows.filter((_, index) => index !== rowIndex))
if (isPreview || rows.length === 1) return
setStoreValue(rows.filter((_, index) => index !== rowIndex))
}
const renderHeader = () => (
@@ -172,6 +185,7 @@ export function Table({ columns, blockId, subBlockId }: TableProps) {
setActiveCell(null)
}
}}
disabled={isPreview}
className='w-full border-0 text-transparent caret-foreground placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0'
/>
<div
@@ -186,7 +200,8 @@ export function Table({ columns, blockId, subBlockId }: TableProps) {
}
const renderDeleteButton = (rowIndex: number) =>
rows.length > 1 && (
rows.length > 1 &&
!isPreview && (
<td className='w-0 p-0'>
<Button
variant='ghost'

View File

@@ -12,11 +12,23 @@ interface TimeInputProps {
blockId: string
subBlockId: string
placeholder?: string
isPreview?: boolean
previewValue?: string | null
className?: string
}
export function TimeInput({ blockId, subBlockId, placeholder, className }: TimeInputProps) {
const [value, setValue] = useSubBlockValue<string>(blockId, subBlockId, true)
export function TimeInput({
blockId,
subBlockId,
placeholder,
isPreview = false,
previewValue,
className,
}: TimeInputProps) {
const [storeValue, setStoreValue] = useSubBlockValue<string>(blockId, subBlockId)
// Use preview value when in preview mode, otherwise use store value
const value = isPreview ? previewValue : storeValue
const [isOpen, setIsOpen] = React.useState(false)
// Convert 24h time string to display format (12h with AM/PM)
@@ -41,10 +53,11 @@ export function TimeInput({ blockId, subBlockId, placeholder, className }: TimeI
// Update the time when any component changes
const updateTime = (newHour?: string, newMinute?: string, newAmpm?: 'AM' | 'PM') => {
if (isPreview) return
const h = Number.parseInt(newHour ?? hour) || 12
const m = Number.parseInt(newMinute ?? minute) || 0
const p = newAmpm ?? ampm
setValue(formatStorageTime(h, m, p))
setStoreValue(formatStorageTime(h, m, p))
}
// Initialize from existing value
@@ -78,6 +91,7 @@ export function TimeInput({ blockId, subBlockId, placeholder, className }: TimeI
<PopoverTrigger asChild>
<Button
variant='outline'
disabled={isPreview}
className={cn(
'w-full justify-start text-left font-normal',
!value && 'text-muted-foreground',

View File

@@ -31,6 +31,8 @@ import { ToolCommand } from './components/tool-command/tool-command'
interface ToolInputProps {
blockId: string
subBlockId: string
isPreview?: boolean
previewValue?: any
}
interface StoredTool {
@@ -270,8 +272,13 @@ const shouldBePasswordField = (blockType: string, paramId: string): boolean => {
return false
}
export function ToolInput({ blockId, subBlockId }: ToolInputProps) {
const [value, setValue] = useSubBlockValue(blockId, subBlockId)
export function ToolInput({
blockId,
subBlockId,
isPreview = false,
previewValue,
}: ToolInputProps) {
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId)
const [open, setOpen] = useState(false)
const [customToolModalOpen, setCustomToolModalOpen] = useState(false)
const [editingToolIndex, setEditingToolIndex] = useState<number | null>(null)
@@ -289,6 +296,9 @@ export function ToolInput({ blockId, subBlockId }: ToolInputProps) {
const toolBlocks = getAllBlocks().filter((block) => block.category === 'tools')
// Use preview value when in preview mode, otherwise use store value
const value = isPreview ? previewValue : storeValue
// Custom filter function for the Command component
const customFilter = useCallback((value: string, search: string) => {
if (!search.trim()) return 1
@@ -314,6 +324,11 @@ export function ToolInput({ blockId, subBlockId }: ToolInputProps) {
? (value as unknown as StoredTool[])
: []
// Check if a tool is already selected
const isToolAlreadySelected = (toolType: string) => {
return selectedTools.some((tool) => tool.type === toolType)
}
const handleSelectTool = (toolBlock: (typeof toolBlocks)[0]) => {
const hasOperations = hasMultipleOperations(toolBlock.type)
const operationOptions = hasOperations ? getOperationOptions(toolBlock.type) : []
@@ -342,7 +357,7 @@ export function ToolInput({ blockId, subBlockId }: ToolInputProps) {
// If isWide, keep tools in the same row expanded
if (isWide) {
setValue([
setStoreValue([
...selectedTools.map((tool, index) => ({
...tool,
// Keep expanded if it's in the same row as the new tool
@@ -352,7 +367,7 @@ export function ToolInput({ blockId, subBlockId }: ToolInputProps) {
])
} else {
// Original behavior for non-wide mode
setValue([...selectedTools.map((tool) => ({ ...tool, isExpanded: false })), newTool])
setStoreValue([...selectedTools.map((tool) => ({ ...tool, isExpanded: false })), newTool])
}
setOpen(false)
@@ -397,7 +412,7 @@ export function ToolInput({ blockId, subBlockId }: ToolInputProps) {
// If isWide, keep tools in the same row expanded
if (isWide) {
setValue([
setStoreValue([
...selectedTools.map((tool, index) => ({
...tool,
// Keep expanded if it's in the same row as the new tool
@@ -407,7 +422,7 @@ export function ToolInput({ blockId, subBlockId }: ToolInputProps) {
])
} else {
// Original behavior for non-wide mode
setValue([...selectedTools.map((tool) => ({ ...tool, isExpanded: false })), newTool])
setStoreValue([...selectedTools.map((tool) => ({ ...tool, isExpanded: false })), newTool])
}
}
@@ -428,7 +443,7 @@ export function ToolInput({ blockId, subBlockId }: ToolInputProps) {
const handleSaveCustomTool = (customTool: CustomTool) => {
if (editingToolIndex !== null) {
// Update existing tool
setValue(
setStoreValue(
selectedTools.map((tool, index) =>
index === editingToolIndex
? {
@@ -448,7 +463,7 @@ export function ToolInput({ blockId, subBlockId }: ToolInputProps) {
}
const handleRemoveTool = (toolType: string, toolIndex: number) => {
setValue(selectedTools.filter((_, index) => index !== toolIndex))
setStoreValue(selectedTools.filter((_, index) => index !== toolIndex))
}
// New handler for when a custom tool is completely deleted from the store
@@ -472,7 +487,7 @@ export function ToolInput({ blockId, subBlockId }: ToolInputProps) {
// Update the workflow value if any tools were removed
if (updatedTools.length !== selectedTools.length) {
setValue(updatedTools)
setStoreValue(updatedTools)
}
}
@@ -490,7 +505,7 @@ export function ToolInput({ blockId, subBlockId }: ToolInputProps) {
}
// Update the value in the workflow
setValue(
setStoreValue(
selectedTools.map((tool, index) =>
index === toolIndex
? {
@@ -519,7 +534,7 @@ export function ToolInput({ blockId, subBlockId }: ToolInputProps) {
subBlockStore.setValue(blockId, 'parentIssue', '')
}
setValue(
setStoreValue(
selectedTools.map((tool, index) =>
index === toolIndex
? {
@@ -534,7 +549,7 @@ export function ToolInput({ blockId, subBlockId }: ToolInputProps) {
}
const handleCredentialChange = (toolIndex: number, credentialId: string) => {
setValue(
setStoreValue(
selectedTools.map((tool, index) =>
index === toolIndex
? {
@@ -550,7 +565,7 @@ export function ToolInput({ blockId, subBlockId }: ToolInputProps) {
}
const handleUsageControlChange = (toolIndex: number, usageControl: string) => {
setValue(
setStoreValue(
selectedTools.map((tool, index) =>
index === toolIndex
? {
@@ -563,7 +578,7 @@ export function ToolInput({ blockId, subBlockId }: ToolInputProps) {
}
const toggleToolExpansion = (toolIndex: number) => {
setValue(
setStoreValue(
selectedTools.map((tool, index) =>
index === toolIndex ? { ...tool, isExpanded: !tool.isExpanded } : tool
)
@@ -596,10 +611,13 @@ export function ToolInput({ blockId, subBlockId }: ToolInputProps) {
<ToolCommand.Item
value='Create Tool'
onSelect={() => {
setOpen(false)
setCustomToolModalOpen(true)
if (!isPreview) {
setCustomToolModalOpen(true)
setOpen(false)
}
}}
className='mb-1 flex cursor-pointer items-center gap-2'
disabled={isPreview}
>
<div className='flex h-6 w-6 items-center justify-center rounded border border-muted-foreground/50 border-dashed bg-transparent'>
<WrenchIcon className='h-4 w-4 text-muted-foreground' />
@@ -631,7 +649,7 @@ export function ToolInput({ blockId, subBlockId }: ToolInputProps) {
}
if (isWide) {
setValue([
setStoreValue([
...selectedTools.map((tool, index) => ({
...tool,
isExpanded:
@@ -641,7 +659,7 @@ export function ToolInput({ blockId, subBlockId }: ToolInputProps) {
newTool,
])
} else {
setValue([
setStoreValue([
...selectedTools.map((tool) => ({
...tool,
isExpanded: false,
@@ -933,50 +951,52 @@ export function ToolInput({ blockId, subBlockId }: ToolInputProps) {
}
return (
<div key={param.id} className='relative space-y-1.5'>
<div className='flex items-center font-medium text-muted-foreground text-xs'>
{formatParamId(param.id)}
{param.optionalToolInput && !param.requiredForToolCall && (
<span className='ml-1 text-muted-foreground/60 text-xs'>
(Optional)
</span>
)}
</div>
<div className='relative'>
{useChannelSelector && channelSelectorConfig ? (
<ChannelSelectorInput
blockId={blockId}
subBlock={{
id: param.id,
type: 'channel-selector',
title: channelSelectorConfig.title || formatParamId(param.id),
provider: channelSelectorConfig.provider,
placeholder:
channelSelectorConfig.placeholder || param.description,
}}
credential={credentialForChannelSelector}
onChannelSelect={(channelId) => {
handleParamChange(toolIndex, param.id, channelId)
}}
/>
) : (
<ShortInput
blockId={blockId}
subBlockId={`${subBlockId}-param`}
placeholder={param.description}
password={shouldBePasswordField(tool.type, param.id)}
isConnecting={false}
config={{
id: `${subBlockId}-param`,
type: 'short-input',
title: param.id,
}}
value={tool.params[param.id] || ''}
onChange={(value) =>
handleParamChange(toolIndex, param.id, value)
}
/>
)}
<div key={param.id}>
<div className='relative space-y-1.5'>
<div className='flex items-center font-medium text-muted-foreground text-xs'>
{formatParamId(param.id)}
{param.optionalToolInput && !param.requiredForToolCall && (
<span className='ml-1 text-muted-foreground/60 text-xs'>
(Optional)
</span>
)}
</div>
<div className='relative'>
{useChannelSelector && channelSelectorConfig ? (
<ChannelSelectorInput
blockId={blockId}
subBlock={{
id: param.id,
type: 'channel-selector',
title: channelSelectorConfig.title || formatParamId(param.id),
provider: channelSelectorConfig.provider,
placeholder:
channelSelectorConfig.placeholder || param.description,
}}
credential={credentialForChannelSelector}
onChannelSelect={(channelId) => {
handleParamChange(toolIndex, param.id, channelId)
}}
/>
) : (
<ShortInput
blockId={blockId}
subBlockId={`${subBlockId}-param`}
placeholder={param.description}
password={shouldBePasswordField(tool.type, param.id)}
isConnecting={false}
config={{
id: `${subBlockId}-param`,
type: 'short-input',
title: param.id,
}}
value={tool.params[param.id] || ''}
onChange={(value) =>
handleParamChange(toolIndex, param.id, value)
}
/>
)}
</div>
</div>
</div>
)
@@ -987,6 +1007,7 @@ export function ToolInput({ blockId, subBlockId }: ToolInputProps) {
</div>
)
})}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
@@ -1042,7 +1063,7 @@ export function ToolInput({ blockId, subBlockId }: ToolInputProps) {
}
if (isWide) {
setValue([
setStoreValue([
...selectedTools.map((tool, index) => ({
...tool,
isExpanded:
@@ -1052,7 +1073,7 @@ export function ToolInput({ blockId, subBlockId }: ToolInputProps) {
newTool,
])
} else {
setValue([
setStoreValue([
...selectedTools.map((tool) => ({
...tool,
isExpanded: false,

View File

@@ -287,9 +287,21 @@ interface WebhookConfigProps {
blockId: string
subBlockId?: string
isConnecting: boolean
isPreview?: boolean
value?: {
webhookProvider?: string
webhookPath?: string
providerConfig?: ProviderConfig
}
}
export function WebhookConfig({ blockId, subBlockId, isConnecting }: WebhookConfigProps) {
export function WebhookConfig({
blockId,
subBlockId,
isConnecting,
isPreview = false,
value: propValue,
}: WebhookConfigProps) {
const [isModalOpen, setIsModalOpen] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
@@ -304,13 +316,18 @@ export function WebhookConfig({ blockId, subBlockId, isConnecting }: WebhookConf
const setWebhookStatus = useWorkflowStore((state) => state.setWebhookStatus)
// Get the webhook provider from the block state
const [webhookProvider, setWebhookProvider] = useSubBlockValue(blockId, 'webhookProvider')
const [storeWebhookProvider, setWebhookProvider] = useSubBlockValue(blockId, 'webhookProvider')
// Store the webhook path
const [webhookPath, setWebhookPath] = useSubBlockValue(blockId, 'webhookPath')
const [storeWebhookPath, setWebhookPath] = useSubBlockValue(blockId, 'webhookPath')
// Store provider-specific configuration
const [_providerConfig, setProviderConfig] = useSubBlockValue(blockId, 'providerConfig')
const [storeProviderConfig, setProviderConfig] = useSubBlockValue(blockId, 'providerConfig')
// Use prop values when available (preview mode), otherwise use store values
const webhookProvider = propValue?.webhookProvider ?? storeWebhookProvider
const webhookPath = propValue?.webhookPath ?? storeWebhookPath
const providerConfig = propValue?.providerConfig ?? storeProviderConfig
// Reset provider config when provider changes
useEffect(() => {
@@ -325,6 +342,12 @@ export function WebhookConfig({ blockId, subBlockId, isConnecting }: WebhookConf
// Check if webhook exists in the database
useEffect(() => {
// Skip API calls in preview mode
if (isPreview) {
setIsLoading(false)
return
}
const checkWebhook = async () => {
setIsLoading(true)
try {
@@ -374,6 +397,7 @@ export function WebhookConfig({ blockId, subBlockId, isConnecting }: WebhookConf
setWebhookPath,
setWebhookProvider,
setWebhookStatus,
isPreview,
])
const handleOpenModal = () => {
@@ -386,6 +410,9 @@ export function WebhookConfig({ blockId, subBlockId, isConnecting }: WebhookConf
}
const handleSaveWebhook = async (path: string, config: ProviderConfig) => {
// Prevent saving in preview mode
if (isPreview) return false
try {
setIsSaving(true)
setError(null)
@@ -449,7 +476,8 @@ export function WebhookConfig({ blockId, subBlockId, isConnecting }: WebhookConf
}
const handleDeleteWebhook = async () => {
if (!webhookId) return false
// Prevent deletion in preview mode
if (isPreview || !webhookId) return false
try {
setIsDeleting(true)
@@ -543,7 +571,7 @@ export function WebhookConfig({ blockId, subBlockId, isConnecting }: WebhookConf
'https://www.googleapis.com/auth/gmail.labels',
]}
label='Select Gmail account'
disabled={isConnecting || isSaving || isDeleting}
disabled={isConnecting || isSaving || isDeleting || isPreview}
/>
</div>
@@ -553,7 +581,7 @@ export function WebhookConfig({ blockId, subBlockId, isConnecting }: WebhookConf
size='sm'
className='flex h-10 w-full items-center bg-background font-normal text-sm'
onClick={handleOpenModal}
disabled={isConnecting || isSaving || isDeleting || !gmailCredentialId}
disabled={isConnecting || isSaving || isDeleting || !gmailCredentialId || isPreview}
>
{isLoading ? (
<div className='mr-2 h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
@@ -605,7 +633,7 @@ export function WebhookConfig({ blockId, subBlockId, isConnecting }: WebhookConf
size='sm'
className='flex h-10 w-full items-center bg-background font-normal text-sm'
onClick={handleOpenModal}
disabled={isConnecting || isSaving || isDeleting}
disabled={isConnecting || isSaving || isDeleting || isPreview}
>
{isLoading ? (
<div className='mr-2 h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />

View File

@@ -3,9 +3,7 @@ import { Label } from '@/components/ui/label'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { getBlock } from '@/blocks/index'
import type { SubBlockConfig } from '@/blocks/types'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { ChannelSelectorInput } from './components/channel-selector/channel-selector-input'
import { CheckboxList } from './components/checkbox-list'
import { Code } from './components/code'
import { ConditionInput } from './components/condition-input'
@@ -32,15 +30,21 @@ interface SubBlockProps {
blockId: string
config: SubBlockConfig
isConnecting: boolean
isPreview?: boolean
subBlockValues?: Record<string, any>
}
export function SubBlock({ blockId, config, isConnecting }: SubBlockProps) {
export function SubBlock({
blockId,
config,
isConnecting,
isPreview = false,
subBlockValues,
}: SubBlockProps) {
const handleMouseDown = (e: React.MouseEvent) => {
e.stopPropagation()
}
const { getValue } = useSubBlockStore()
const isFieldRequired = () => {
const blockType = useWorkflowStore.getState().blocks[blockId]?.type
if (!blockType) return false
@@ -51,7 +55,15 @@ export function SubBlock({ blockId, config, isConnecting }: SubBlockProps) {
return blockConfig.inputs[config.id]?.required === true
}
// Get preview value for this specific sub-block
const getPreviewValue = () => {
if (!isPreview || !subBlockValues) return undefined
return subBlockValues[config.id]?.value ?? null
}
const renderInput = () => {
const previewValue = getPreviewValue()
switch (config.type) {
case 'short-input':
return (
@@ -62,6 +74,8 @@ export function SubBlock({ blockId, config, isConnecting }: SubBlockProps) {
password={config.password}
isConnecting={isConnecting}
config={config}
isPreview={isPreview}
previewValue={previewValue}
/>
)
case 'long-input':
@@ -73,6 +87,8 @@ export function SubBlock({ blockId, config, isConnecting }: SubBlockProps) {
isConnecting={isConnecting}
rows={config.rows}
config={config}
isPreview={isPreview}
previewValue={previewValue}
/>
)
case 'dropdown':
@@ -82,6 +98,8 @@ export function SubBlock({ blockId, config, isConnecting }: SubBlockProps) {
blockId={blockId}
subBlockId={config.id}
options={config.options as string[]}
isPreview={isPreview}
previewValue={previewValue}
/>
</div>
)
@@ -95,10 +113,20 @@ export function SubBlock({ blockId, config, isConnecting }: SubBlockProps) {
defaultValue={(config.min || 0) + ((config.max || 100) - (config.min || 0)) / 2}
step={config.step}
integer={config.integer}
isPreview={isPreview}
previewValue={previewValue}
/>
)
case 'table':
return <Table blockId={blockId} subBlockId={config.id} columns={config.columns ?? []} />
return (
<Table
blockId={blockId}
subBlockId={config.id}
columns={config.columns ?? []}
isPreview={isPreview}
previewValue={previewValue}
/>
)
case 'code':
return (
<Code
@@ -108,12 +136,29 @@ export function SubBlock({ blockId, config, isConnecting }: SubBlockProps) {
placeholder={config.placeholder}
language={config.language}
generationType={config.generationType}
isPreview={isPreview}
previewValue={previewValue}
/>
)
case 'switch':
return <Switch blockId={blockId} subBlockId={config.id} title={config.title ?? ''} />
return (
<Switch
blockId={blockId}
subBlockId={config.id}
title={config.title ?? ''}
isPreview={isPreview}
previewValue={previewValue}
/>
)
case 'tool-input':
return <ToolInput blockId={blockId} subBlockId={config.id} />
return (
<ToolInput
blockId={blockId}
subBlockId={config.id}
isPreview={isPreview}
previewValue={previewValue}
/>
)
case 'checkbox-list':
return (
<CheckboxList
@@ -122,21 +167,48 @@ export function SubBlock({ blockId, config, isConnecting }: SubBlockProps) {
title={config.title ?? ''}
options={config.options as { label: string; id: string }[]}
layout={config.layout}
isPreview={isPreview}
subBlockValues={subBlockValues}
/>
)
case 'condition-input':
return (
<ConditionInput blockId={blockId} subBlockId={config.id} isConnecting={isConnecting} />
<ConditionInput
blockId={blockId}
subBlockId={config.id}
isConnecting={isConnecting}
isPreview={isPreview}
previewValue={previewValue}
/>
)
case 'eval-input':
return <EvalInput blockId={blockId} subBlockId={config.id} />
return (
<EvalInput
blockId={blockId}
subBlockId={config.id}
isPreview={isPreview}
previewValue={previewValue}
/>
)
case 'date-input':
return (
<DateInput blockId={blockId} subBlockId={config.id} placeholder={config.placeholder} />
<DateInput
blockId={blockId}
subBlockId={config.id}
placeholder={config.placeholder}
isPreview={isPreview}
previewValue={previewValue}
/>
)
case 'time-input':
return (
<TimeInput blockId={blockId} subBlockId={config.id} placeholder={config.placeholder} />
<TimeInput
blockId={blockId}
subBlockId={config.id}
placeholder={config.placeholder}
isPreview={isPreview}
previewValue={previewValue}
/>
)
case 'file-upload':
return (
@@ -146,47 +218,106 @@ export function SubBlock({ blockId, config, isConnecting }: SubBlockProps) {
acceptedTypes={config.acceptedTypes || '*'}
multiple={config.multiple === true}
maxSize={config.maxSize}
isPreview={isPreview}
previewValue={previewValue}
/>
)
case 'webhook-config':
case 'webhook-config': {
// For webhook config, we need to construct the value from multiple subblock values
const webhookValue =
isPreview && subBlockValues
? {
webhookProvider: subBlockValues.webhookProvider?.value,
webhookPath: subBlockValues.webhookPath?.value,
providerConfig: subBlockValues.providerConfig?.value,
}
: previewValue
return (
<WebhookConfig blockId={blockId} subBlockId={config.id} isConnecting={isConnecting} />
<WebhookConfig
blockId={blockId}
subBlockId={config.id}
isConnecting={isConnecting}
isPreview={isPreview}
value={webhookValue}
/>
)
}
case 'schedule-config':
return (
<ScheduleConfig blockId={blockId} subBlockId={config.id} isConnecting={isConnecting} />
<ScheduleConfig
blockId={blockId}
subBlockId={config.id}
isConnecting={isConnecting}
isPreview={isPreview}
previewValue={previewValue}
/>
)
case 'oauth-input':
return (
<CredentialSelector
value={typeof config.value === 'string' ? config.value : ''}
value={
isPreview ? previewValue || '' : typeof config.value === 'string' ? config.value : ''
}
onChange={(value) => {
// Use the workflow store to update the value
const event = new CustomEvent('update-subblock-value', {
detail: {
blockId,
subBlockId: config.id,
value,
},
})
window.dispatchEvent(event)
// Only allow changes in non-preview mode
if (!isPreview) {
const event = new CustomEvent('update-subblock-value', {
detail: {
blockId,
subBlockId: config.id,
value,
},
})
window.dispatchEvent(event)
}
}}
provider={config.provider as any}
requiredScopes={config.requiredScopes || []}
label={config.placeholder || 'Select a credential'}
serviceId={config.serviceId}
disabled={isPreview}
/>
)
case 'file-selector':
return <FileSelectorInput blockId={blockId} subBlock={config} disabled={isConnecting} />
return (
<FileSelectorInput
blockId={blockId}
subBlock={config}
disabled={isConnecting || isPreview}
isPreview={isPreview}
previewValue={previewValue}
/>
)
case 'project-selector':
return <ProjectSelectorInput blockId={blockId} subBlock={config} disabled={isConnecting} />
case 'channel-selector':
return <ChannelSelectorInput blockId={blockId} subBlock={config} disabled={isConnecting} />
return (
<ProjectSelectorInput
blockId={blockId}
subBlock={config}
disabled={isConnecting || isPreview}
isPreview={isPreview}
previewValue={previewValue}
/>
)
case 'folder-selector':
return <FolderSelectorInput blockId={blockId} subBlock={config} disabled={isConnecting} />
return (
<FolderSelectorInput
blockId={blockId}
subBlock={config}
disabled={isConnecting || isPreview}
isPreview={isPreview}
previewValue={previewValue}
/>
)
case 'input-format':
return <InputFormat blockId={blockId} subBlockId={config.id} />
return (
<InputFormat
blockId={blockId}
subBlockId={config.id}
isPreview={isPreview}
previewValue={previewValue}
/>
)
default:
return <div>Unknown input type: {config.type}</div>
}

View File

@@ -22,6 +22,8 @@ interface WorkflowBlockProps {
name: string
isActive?: boolean
isPending?: boolean
isPreview?: boolean
subBlockValues?: Record<string, any>
}
// Combine both interfaces into a single component
@@ -30,8 +32,10 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
// State management
const [isConnecting, setIsConnecting] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const [editedName, setEditedName] = useState('')
const [isLoadingScheduleInfo, setIsLoadingScheduleInfo] = useState(false)
const [scheduleInfo, setScheduleInfo] = useState<{
scheduleTiming: string
nextRunAt: string | null
@@ -41,7 +45,6 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
isDisabled?: boolean
id?: string
} | null>(null)
const [isLoadingScheduleInfo, setIsLoadingScheduleInfo] = useState(false)
const [webhookInfo, setWebhookInfo] = useState<{
webhookPath: string
provider: string
@@ -174,6 +177,7 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
}
}, [type])
// Get webhook information for the tooltip
useEffect(() => {
if (type === 'starter' && hasActiveWebhook) {
const fetchWebhookInfo = async () => {
@@ -203,6 +207,7 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
}
}, [type, hasActiveWebhook])
// Update node internals when handles change
useEffect(() => {
updateNodeInternals(id)
}, [id, horizontalHandles, updateNodeInternals])
@@ -215,6 +220,7 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
}
}
// Add effect to observe size changes with debounced updates
useEffect(() => {
if (!contentRef.current) return
@@ -227,10 +233,12 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
}, 100)
const resizeObserver = new ResizeObserver((entries) => {
// Cancel any pending animation frame
if (rafId) {
cancelAnimationFrame(rafId)
}
// Schedule the update on the next animation frame
rafId = requestAnimationFrame(() => {
for (const entry of entries) {
const height =
@@ -256,10 +264,20 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
let currentRow: SubBlockConfig[] = []
let currentRowWidth = 0
// Get merged state for this block
const blocks = useWorkflowStore.getState().blocks
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId || undefined
const mergedState = mergeSubblockState(blocks, activeWorkflowId, blockId)[blockId]
// Get the appropriate state for conditional evaluation
let stateToUse: Record<string, any> = {}
if (data.isPreview && data.subBlockValues) {
// In preview mode, use the preview values
stateToUse = data.subBlockValues
} else {
// In normal mode, use merged state
const blocks = useWorkflowStore.getState().blocks
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId || undefined
const mergedState = mergeSubblockState(blocks, activeWorkflowId, blockId)[blockId]
stateToUse = mergedState?.subBlocks || {}
}
const isAdvancedMode = useWorkflowStore.getState().blocks[blockId]?.advancedMode ?? false
// Filter visible blocks and those that meet their conditions
@@ -275,10 +293,10 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
// If there's no condition, the block should be shown
if (!block.condition) return true
// Get the values of the fields this block depends on from merged state
const fieldValue = mergedState?.subBlocks[block.condition.field]?.value
// Get the values of the fields this block depends on from the appropriate state
const fieldValue = stateToUse[block.condition.field]?.value
const andFieldValue = block.condition.and
? mergedState?.subBlocks[block.condition.and.field]?.value
? stateToUse[block.condition.and.field]?.value
: undefined
// Check if the condition value is an array
@@ -365,7 +383,7 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
}
}
// Check if this is a starter block and if we need to show schedule / webhook indicators
// Check if this is a starter block and has active schedule or webhook
const isStarterBlock = type === 'starter'
const showWebhookIndicator = isStarterBlock && hasActiveWebhook
@@ -703,7 +721,13 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
key={`${id}-${rowIndex}-${blockIndex}`}
className={cn('space-y-1', subBlock.layout === 'half' ? 'flex-1' : 'w-full')}
>
<SubBlock blockId={id} config={subBlock} isConnecting={isConnecting} />
<SubBlock
blockId={id}
config={subBlock}
isConnecting={isConnecting}
isPreview={data.isPreview}
subBlockValues={data.subBlockValues}
/>
</div>
))}
</div>

View File

@@ -1,285 +0,0 @@
import { useEffect, useState } from 'react'
import { createLogger } from '@/lib/logs/console-logger'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const logger = createLogger('useDeploymentChangeDetection')
/**
* Hook to detect when a deployed workflow needs redeployment due to changes
* Handles debouncing, API checks, and state synchronization
*/
export function useDeploymentChangeDetection(activeWorkflowId: string | null, isDeployed: boolean) {
const [needsRedeployment, setNeedsRedeployment] = useState(false)
// Listen for workflow changes and check if redeployment is needed
useEffect(() => {
if (!activeWorkflowId || !isDeployed) return
// Create a debounced function to check for changes
let debounceTimer: NodeJS.Timeout | null = null
let lastCheckTime = 0
let pendingChanges = 0
const DEBOUNCE_DELAY = 1000
const THROTTLE_INTERVAL = 3000
// Store the current workflow ID when the effect runs
const effectWorkflowId = activeWorkflowId
// Function to check if redeployment is needed
const checkForChanges = async () => {
// No longer skip if we're already showing needsRedeployment
// This allows us to detect when changes have been reverted
// Reset the pending changes counter
pendingChanges = 0
lastCheckTime = Date.now()
// Store the current workflow ID to check for race conditions
const requestedWorkflowId = activeWorkflowId
logger.debug(`Checking for changes in workflow ${requestedWorkflowId}`)
try {
// Get the deployed state from the API
const response = await fetch(`/api/workflows/${requestedWorkflowId}/status`)
if (response.ok) {
const data = await response.json()
// Verify the active workflow hasn't changed while fetching
if (requestedWorkflowId !== activeWorkflowId) {
logger.debug(
`Ignoring changes response for ${requestedWorkflowId} - no longer the active workflow`
)
return
}
logger.debug(
`API needsRedeployment response for workflow ${requestedWorkflowId}: ${data.needsRedeployment}`
)
// Always update the needsRedeployment flag based on API response to handle both true and false
// This ensures it's updated when changes are detected and when changes are no longer detected
if (data.needsRedeployment) {
logger.info(
`Setting needsRedeployment flag to TRUE for workflow ${requestedWorkflowId}`
)
// Update local state
setNeedsRedeployment(true)
// Use the workflow-specific method to update the registry
useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(requestedWorkflowId, true)
} else {
// Only update to false if the current state is true to avoid unnecessary updates
const currentStatus = useWorkflowRegistry
.getState()
.getWorkflowDeploymentStatus(requestedWorkflowId)
if (currentStatus?.needsRedeployment) {
logger.info(
`Setting needsRedeployment flag to FALSE for workflow ${requestedWorkflowId}`
)
// Update local state
setNeedsRedeployment(false)
// Use the workflow-specific method to update the registry
useWorkflowRegistry
.getState()
.setWorkflowNeedsRedeployment(requestedWorkflowId, false)
}
}
}
} catch (error) {
logger.error('Failed to check workflow change status:', { error })
}
}
// Debounced check function
const debouncedCheck = () => {
// Skip if the active workflow has changed
if (effectWorkflowId !== activeWorkflowId) {
return
}
// Increment the pending changes counter
pendingChanges++
// Clear any existing timer
if (debounceTimer) {
clearTimeout(debounceTimer)
}
// If we recently checked, and it's within throttle interval, wait longer
const timeElapsed = Date.now() - lastCheckTime
if (timeElapsed < THROTTLE_INTERVAL && lastCheckTime > 0) {
// Wait until the throttle interval has passed
const adjustedDelay = Math.max(THROTTLE_INTERVAL - timeElapsed, DEBOUNCE_DELAY)
debounceTimer = setTimeout(() => {
// Only check if we have pending changes and workflow ID hasn't changed
if (pendingChanges > 0 && effectWorkflowId === activeWorkflowId) {
checkForChanges()
}
}, adjustedDelay)
} else {
// Standard debounce delay if we haven't checked recently
debounceTimer = setTimeout(() => {
// Only check if we have pending changes and workflow ID hasn't changed
if (pendingChanges > 0 && effectWorkflowId === activeWorkflowId) {
checkForChanges()
}
}, DEBOUNCE_DELAY)
}
}
// Subscribe to workflow store changes
const workflowUnsubscribe = useWorkflowStore.subscribe(debouncedCheck)
// Also subscribe to subblock store changes
const subBlockUnsubscribe = useSubBlockStore.subscribe((state) => {
// Only check for the active workflow when it's deployed
if (!activeWorkflowId || !isDeployed) return
// Skip if the workflow ID has changed since this effect started
if (effectWorkflowId !== activeWorkflowId) {
return
}
// Only trigger when there is an update to the current workflow's subblocks
const workflowSubBlocks = state.workflowValues[effectWorkflowId]
if (workflowSubBlocks && Object.keys(workflowSubBlocks).length > 0) {
debouncedCheck()
}
})
// Set up a periodic check when needsRedeployment is true to ensure it gets set back to false
// when changes are reverted
let periodicCheckTimer: NodeJS.Timeout | null = null
if (needsRedeployment) {
// Check every 5 seconds when needsRedeployment is true to catch reverted changes
const PERIODIC_CHECK_INTERVAL = 5000 // 5 seconds
periodicCheckTimer = setInterval(() => {
// Only perform the check if this is still the active workflow
if (effectWorkflowId === activeWorkflowId) {
checkForChanges()
} else {
// Clear the interval if the workflow has changed
if (periodicCheckTimer) {
clearInterval(periodicCheckTimer)
}
}
}, PERIODIC_CHECK_INTERVAL)
}
return () => {
if (debounceTimer) {
clearTimeout(debounceTimer)
}
if (periodicCheckTimer) {
clearInterval(periodicCheckTimer)
}
workflowUnsubscribe()
subBlockUnsubscribe()
}
}, [activeWorkflowId, isDeployed, needsRedeployment])
// Initial check on mount or when active workflow changes
useEffect(() => {
async function checkDeploymentStatus() {
if (!activeWorkflowId) return
try {
// Store the current workflow ID to check for race conditions
const requestedWorkflowId = activeWorkflowId
const response = await fetch(`/api/workflows/${requestedWorkflowId}/status`)
if (response.ok) {
const data = await response.json()
// Verify the active workflow hasn't changed while fetching
if (requestedWorkflowId !== activeWorkflowId) {
return
}
// Update the store with the status from the API
useWorkflowRegistry
.getState()
.setDeploymentStatus(
requestedWorkflowId,
data.isDeployed,
data.deployedAt ? new Date(data.deployedAt) : undefined
)
// Update local state
setNeedsRedeployment(data.needsRedeployment)
// Use the workflow-specific method to update the registry
useWorkflowRegistry
.getState()
.setWorkflowNeedsRedeployment(requestedWorkflowId, data.needsRedeployment)
}
} catch (error) {
logger.error('Failed to check workflow status:', { error })
}
}
checkDeploymentStatus()
}, [activeWorkflowId])
// Listen for deployment status changes
useEffect(() => {
// When deployment status changes and isDeployed becomes true,
// that means a deployment just occurred, so reset the needsRedeployment flag
if (isDeployed) {
// Update local state
setNeedsRedeployment(false)
// Use the workflow-specific method to update the registry
if (activeWorkflowId) {
useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(activeWorkflowId, false)
}
}
}, [isDeployed, activeWorkflowId])
// Add a listener for the needsRedeployment flag in the workflow store
useEffect(() => {
const unsubscribe = useWorkflowStore.subscribe((state) => {
// Only update local state when it's for the currently active workflow
if (state.needsRedeployment !== undefined) {
// Get the workflow-specific needsRedeployment flag for the current workflow
const currentWorkflowStatus = useWorkflowRegistry
.getState()
.getWorkflowDeploymentStatus(activeWorkflowId)
// Only set local state based on current workflow's status
if (currentWorkflowStatus?.needsRedeployment !== undefined) {
setNeedsRedeployment(currentWorkflowStatus.needsRedeployment)
} else {
// Fallback to global state only if we don't have workflow-specific status
setNeedsRedeployment(state.needsRedeployment)
}
}
})
return () => unsubscribe()
}, [activeWorkflowId])
// Function to clear the redeployment flag
const clearNeedsRedeployment = () => {
// Update local state
setNeedsRedeployment(false)
// Use the workflow-specific method to update the registry
if (activeWorkflowId) {
useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(activeWorkflowId, false)
}
}
return {
needsRedeployment,
setNeedsRedeployment,
clearNeedsRedeployment,
}
}

View File

@@ -203,6 +203,17 @@ function WorkflowContent() {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.shiftKey && event.key === 'L' && !event.ctrlKey && !event.metaKey) {
// Don't trigger if user is typing in an input, textarea, or contenteditable element
const activeElement = document.activeElement
const isEditableElement =
activeElement instanceof HTMLInputElement ||
activeElement instanceof HTMLTextAreaElement ||
activeElement?.hasAttribute('contenteditable')
if (isEditableElement) {
return // Allow normal typing behavior
}
event.preventDefault()
if (cleanup) cleanup()

View File

@@ -1,649 +0,0 @@
'use client'
import { useMemo } from 'react'
import ReactFlow, {
Background,
ConnectionLineType,
type Edge,
type EdgeTypes,
Handle,
type Node,
type NodeProps,
type NodeTypes,
Position,
ReactFlowProvider,
} from 'reactflow'
import 'reactflow/dist/style.css'
import { Card } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/utils'
import { LoopTool } from '@/app/w/[id]/components/loop-node/loop-config'
import { WorkflowEdge } from '@/app/w/[id]/components/workflow-edge/workflow-edge'
// import { LoopInput } from '@/app/w/[id]/components/workflow-loop/components/loop-input/loop-input'
// import { LoopLabel } from '@/app/w/[id]/components/workflow-loop/components/loop-label/loop-label'
// import { createLoopNode } from '@/app/w/[id]/components/workflow-loop/workflow-loop'
import { getBlock } from '@/blocks'
import type { SubBlockConfig } from '@/blocks/types'
interface WorkflowPreviewProps {
// The workflow state to render
workflowState: {
blocks: Record<string, any>
edges: Array<{
id: string
source: string
target: string
sourceHandle?: string
targetHandle?: string
}>
loops: Record<string, any>
}
// Whether to show subblocks
showSubBlocks?: boolean
// Optional className for container styling
className?: string
// Optional height/width overrides
height?: string | number
width?: string | number
isPannable?: boolean
defaultPosition?: { x: number; y: number }
defaultZoom?: number
}
interface ExtendedSubBlockConfig extends SubBlockConfig {
value?: any
}
// Define node types
const nodeTypes: NodeTypes = {
workflowBlock: PreviewWorkflowBlock,
// loopLabel: LoopLabel,
// loopInput: LoopInput,
}
// Define edge types
const edgeTypes: EdgeTypes = {
workflowEdge: WorkflowEdge,
}
/**
* Prepares subblocks by combining block state with block configuration
*/
function prepareSubBlocks(blockSubBlocks: Record<string, any>, blockConfig: any) {
const configSubBlocks = blockConfig?.subBlocks || []
return Object.entries(blockSubBlocks)
.map(([id, subBlock]) => {
const matchingConfig = configSubBlocks.find((config: any) => config.id === id)
const value = subBlock.value
const hasValue = value !== undefined && value !== null && value !== ''
if (!hasValue) return null
return {
...matchingConfig,
...subBlock,
id,
}
})
.filter(Boolean)
}
/**
* Groups subblocks into rows for layout
*/
function groupSubBlocks(subBlocks: ExtendedSubBlockConfig[]) {
const rows: ExtendedSubBlockConfig[][] = []
let currentRow: ExtendedSubBlockConfig[] = []
let currentRowWidth = 0
const visibleSubBlocks = subBlocks.filter((block) => !block.hidden)
visibleSubBlocks.forEach((block) => {
const blockWidth = block.layout === 'half' ? 0.5 : 1
if (currentRowWidth + blockWidth > 1) {
if (currentRow.length > 0) {
rows.push([...currentRow])
}
currentRow = [block]
currentRowWidth = blockWidth
} else {
currentRow.push(block)
currentRowWidth += blockWidth
}
})
if (currentRow.length > 0) {
rows.push(currentRow)
}
return rows
}
/**
* PreviewSubBlock component - Renders a simplified version of a subblock input
* @param config - The configuration for the subblock
*/
function PreviewSubBlock({ config }: { config: ExtendedSubBlockConfig }) {
/**
* Renders a simplified input based on the subblock type
* Creates visual representations of different input types
*/
const renderSimplifiedInput = () => {
switch (config.type) {
case 'short-input':
return (
<div className='h-7 rounded-md border border-input bg-background px-3 py-1.5 text-muted-foreground text-xs'>
{config.password
? '**********************'
: config.id === 'providerConfig' && config.value && typeof config.value === 'object'
? Object.keys(config.value).length === 0
? 'Webhook pending configuration'
: 'Webhook configured'
: config.value || config.placeholder || 'Text input'}
</div>
)
case 'long-input':
return (
<div className='h-16 rounded-md border border-input bg-background p-2 text-muted-foreground text-xs'>
{typeof config.value === 'string'
? config.value.length > 50
? `${config.value.substring(0, 50)}...`
: config.value
: config.placeholder || 'Text area'}
</div>
)
case 'dropdown':
return (
<div className='flex h-7 items-center justify-between rounded-md border border-input bg-background px-3 py-1 text-muted-foreground text-xs'>
<span>
{config.value ||
(Array.isArray(config.options) ? config.options[0] : 'Select option')}
</span>
<svg
xmlns='http://www.w3.org/2000/svg'
width='12'
height='12'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
className='ml-2'
>
<path d='m6 9 6 6 6-6' />
</svg>
</div>
)
case 'switch':
return (
<div className='flex items-center space-x-2'>
<div
className={`h-4 w-8 rounded-full ${config.value ? 'bg-primary' : 'bg-muted'} flex items-center`}
>
<div
className={`h-3 w-3 rounded-full bg-background transition-all ${config.value ? 'ml-4' : 'ml-0.5'}`}
/>
</div>
<span className='text-xs'>{config.title}</span>
</div>
)
case 'checkbox-list':
return (
<div className='h-7 rounded-md border border-input bg-background px-3 py-1 text-muted-foreground text-xs'>
Checkbox list
</div>
)
case 'code':
return (
<div className='h-12 rounded-md border border-input bg-background p-2 font-mono text-muted-foreground text-xs'>
{typeof config.value === 'string'
? 'Code content'
: config.placeholder || 'Code editor'}
</div>
)
case 'tool-input':
return (
<div className='h-7 rounded-md border border-input bg-background px-3 py-1 text-muted-foreground text-xs'>
Tool configuration
</div>
)
case 'oauth-input':
return (
<div className='flex h-7 items-center justify-between rounded-md border border-input bg-background px-3 py-1 text-muted-foreground text-xs'>
<span>
{config.value ? 'Connected account' : config.placeholder || 'Select account'}
</span>
<svg
xmlns='http://www.w3.org/2000/svg'
width='12'
height='12'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
className='ml-2'
>
<path d='m6 9 6 6 6-6' />
</svg>
</div>
)
case 'file-selector':
return (
<div className='flex h-7 items-center justify-between rounded-md border border-input bg-background px-3 py-1 text-muted-foreground text-xs'>
<span>{config.value ? 'File selected' : config.placeholder || 'Select file'}</span>
<svg
xmlns='http://www.w3.org/2000/svg'
width='12'
height='12'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
className='ml-2'
>
<path d='m6 9 6 6 6-6' />
</svg>
</div>
)
case 'folder-selector':
return (
<div className='flex h-7 items-center justify-between rounded-md border border-input bg-background px-3 py-1 text-muted-foreground text-xs'>
<span>{config.value ? 'Folder selected' : config.placeholder || 'Select folder'}</span>
<svg
xmlns='http://www.w3.org/2000/svg'
width='12'
height='12'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
className='ml-2'
>
<path d='m6 9 6 6 6-6' />
</svg>
</div>
)
case 'project-selector':
return (
<div className='flex h-7 items-center justify-between rounded-md border border-input bg-background px-3 py-1 text-muted-foreground text-xs'>
<span>
{config.value ? 'Project selected' : config.placeholder || 'Select project'}
</span>
<svg
xmlns='http://www.w3.org/2000/svg'
width='12'
height='12'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
className='ml-2'
>
<path d='m6 9 6 6 6-6' />
</svg>
</div>
)
case 'condition-input':
return (
<div className='h-16 rounded-md border border-input bg-background p-2 text-muted-foreground text-xs'>
Condition configuration
</div>
)
case 'eval-input':
return (
<div className='h-12 rounded-md border border-input bg-background p-2 font-mono text-muted-foreground text-xs'>
Eval expression
</div>
)
case 'date-input':
return (
<div className='flex h-7 items-center justify-between rounded-md border border-input bg-background px-3 py-1 text-muted-foreground text-xs'>
<span>{config.value || config.placeholder || 'Select date'}</span>
<svg
xmlns='http://www.w3.org/2000/svg'
width='12'
height='12'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
className='ml-2'
>
<rect width='18' height='18' x='3' y='4' rx='2' ry='2' />
<line x1='16' x2='16' y1='2' y2='6' />
<line x1='8' x2='8' y1='2' y2='6' />
<line x1='3' x2='21' y1='10' y2='10' />
</svg>
</div>
)
case 'time-input':
return (
<div className='flex h-7 items-center justify-between rounded-md border border-input bg-background px-3 py-1 text-muted-foreground text-xs'>
<span>{config.value || config.placeholder || 'Select time'}</span>
<svg
xmlns='http://www.w3.org/2000/svg'
width='12'
height='12'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
className='ml-2'
>
<circle cx='12' cy='12' r='10' />
<polyline points='12 6 12 12 16 14' />
</svg>
</div>
)
case 'file-upload':
return (
<div className='flex h-7 items-center justify-center rounded-md border border-input border-dashed bg-background px-3 py-1 text-muted-foreground text-xs'>
{config.value ? 'File uploaded' : 'Upload file'}
</div>
)
case 'webhook-config':
return (
<div className='h-7 rounded-md border border-input bg-background px-3 py-1 text-muted-foreground text-xs'>
Webhook configuration
</div>
)
case 'schedule-config':
return (
<div className='h-7 rounded-md border border-input bg-background px-3 py-1 text-muted-foreground text-xs'>
Schedule configuration
</div>
)
case 'input-format':
return (
<div className='h-7 rounded-md border border-input bg-background px-3 py-1 text-muted-foreground text-xs'>
Input format configuration
</div>
)
case 'slider':
return (
<div className='h-7 px-1 py-2'>
<div className='relative h-2 w-full rounded-full bg-muted'>
<div
className='absolute h-2 rounded-full bg-primary'
style={{ width: `${((config.value || 50) / 100) * 100}%` }}
/>
<div
className='-translate-x-1/2 -translate-y-1/2 absolute top-1/2 h-4 w-4 rounded-full border-2 border-primary bg-background'
style={{ left: `${((config.value || 50) / 100) * 100}%` }}
/>
</div>
</div>
)
default:
return (
<div className='h-7 rounded-md border border-input bg-background px-3 py-1 text-muted-foreground text-xs'>
{config.value !== undefined ? String(config.value) : config.title || 'Input field'}
</div>
)
}
}
return (
<div className='space-y-1'>
{config.type !== 'switch' && <Label className='text-xs'>{config.title}</Label>}
{renderSimplifiedInput()}
</div>
)
}
function PreviewWorkflowBlock({ id, data }: NodeProps<any>) {
const { type, config, name, blockState, showSubBlocks = true, isLoopBlock } = data
// Get block configuration - use LoopTool for loop blocks if config is missing
const blockConfig = useMemo(() => {
if (type === 'loop' && !config) {
return LoopTool
}
return config
}, [type, config])
// Only prepare subblocks if they should be shown
const preparedSubBlocks = useMemo(() => {
if (!showSubBlocks) return []
return prepareSubBlocks(blockState?.subBlocks, blockConfig)
}, [blockState?.subBlocks, blockConfig, showSubBlocks])
// Group subblocks for layout
const subBlockRows = useMemo(() => {
return groupSubBlocks(preparedSubBlocks)
}, [preparedSubBlocks])
return (
<div className='relative'>
<Card
className={cn(
'relative select-none shadow-md',
'transition-block-bg transition-ring',
blockState?.isWide ? 'w-[400px]' : 'w-[260px]'
)}
>
{/* Block Header */}
<div className='flex items-center justify-between border-b p-2'>
<div className='flex items-center gap-2'>
<div
className='flex h-6 w-6 items-center justify-center rounded'
style={{ backgroundColor: config.bgColor }}
>
<config.icon className='h-4 w-4 text-white' />
</div>
<span className='max-w-[180px] truncate font-medium text-sm' title={name}>
{name}
</span>
</div>
{type === 'loop' && (
<div className='text-muted-foreground text-xs'>
{blockState?.data?.loopType === 'forEach' ? 'For Each' : 'For'}
{blockState?.data?.count && ` (${blockState.data.count}x)`}
</div>
)}
</div>
{/* Block Content */}
{showSubBlocks && (
<div className='space-y-2 px-3 py-2'>
{subBlockRows.length > 0 ? (
subBlockRows.map((row, rowIndex) => (
<div key={`row-${rowIndex}`} className='flex gap-2'>
{row.map((subBlock, blockIndex) => (
<div
key={`${id}-${rowIndex}-${blockIndex}`}
className={cn('space-y-1', subBlock.layout === 'half' ? 'flex-1' : 'w-full')}
>
<PreviewSubBlock config={subBlock} />
</div>
))}
</div>
))
) : (
<div className='py-2 text-muted-foreground text-xs'>
{type === 'loop' ? 'Loop configuration' : 'No configured items'}
</div>
)}
</div>
)}
{/* Handles */}
{type !== 'starter' && (
<Handle
type='target'
position={blockState?.horizontalHandles ? Position.Left : Position.Top}
id='target'
className={cn(
'!w-3 !h-3',
'!bg-white !rounded-full !border !border-gray-200',
blockState?.horizontalHandles ? '!left-[-6px]' : '!top-[-6px]'
)}
isConnectable={false}
/>
)}
{type !== 'condition' && (
<Handle
type='source'
position={blockState?.horizontalHandles ? Position.Right : Position.Bottom}
id='source'
className={cn(
'!w-3 !h-3',
'!bg-white !rounded-full !border !border-gray-200',
blockState?.horizontalHandles ? '!right-[-6px]' : '!bottom-[-6px]'
)}
isConnectable={false}
/>
)}
</Card>
</div>
)
}
function WorkflowPreviewContent({
workflowState,
showSubBlocks = true,
className,
height = '100%',
width = '100%',
isPannable = false,
defaultPosition,
defaultZoom,
}: WorkflowPreviewProps) {
// Transform blocks and loops into ReactFlow nodes
const nodes: Node[] = useMemo(() => {
const nodeArray: Node[] = []
// First, get all blocks with parent-child relationships
const blocksWithParents: Record<string, any> = {}
const topLevelBlocks: Record<string, any> = {}
// Categorize blocks as top-level or child blocks
Object.entries(workflowState.blocks).forEach(([blockId, block]) => {
if (block.data?.parentId) {
// This is a child block
blocksWithParents[blockId] = block
} else {
// This is a top-level block
topLevelBlocks[blockId] = block
}
})
// Process top-level blocks first
Object.entries(topLevelBlocks).forEach(([blockId, block]) => {
const blockConfig = getBlock(block.type)
nodeArray.push({
id: blockId,
type: 'workflowBlock',
position: block.position,
data: {
type: block.type,
config: blockConfig || (block.type === 'loop' ? LoopTool : null),
name: block.name,
blockState: block,
showSubBlocks,
},
draggable: false,
})
// Add children of this block if it's a loop
if (block.type === 'loop') {
// Find all children of this loop
const childBlocks = Object.entries(blocksWithParents).filter(
([_, childBlock]) => childBlock.data?.parentId === blockId
)
// Add all child blocks to the node array
childBlocks.forEach(([childId, childBlock]) => {
const childConfig = getBlock(childBlock.type)
nodeArray.push({
id: childId,
type: 'workflowBlock',
// Position child blocks relative to the parent
position: {
x: block.position.x + 50, // Offset children to the right
y: block.position.y + (childBlock.position?.y || 100), // Preserve vertical positioning
},
data: {
type: childBlock.type,
config: childConfig,
name: childBlock.name,
blockState: childBlock,
showSubBlocks,
isChild: true,
parentId: blockId,
},
draggable: false,
})
})
}
})
return nodeArray
}, [workflowState.blocks, showSubBlocks])
// Transform edges
const edges: Edge[] = useMemo(() => {
return workflowState.edges.map((edge) => ({
id: edge.id,
source: edge.source,
target: edge.target,
sourceHandle: edge.sourceHandle,
targetHandle: edge.targetHandle,
type: 'workflowEdge',
}))
}, [workflowState.edges])
return (
<div style={{ height, width }} className={className}>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
connectionLineType={ConnectionLineType.SmoothStep}
fitView
panOnScroll={false}
panOnDrag={isPannable}
zoomOnScroll={false}
draggable={false}
defaultViewport={{
x: defaultPosition?.x ?? 0,
y: defaultPosition?.y ?? 0,
zoom: defaultZoom ?? 1,
}}
minZoom={0.1}
maxZoom={2}
proOptions={{ hideAttribution: true }}
>
<Background />
</ReactFlow>
</div>
)
}
export function WorkflowPreview(props: WorkflowPreviewProps) {
return (
<ReactFlowProvider>
<WorkflowPreviewContent {...props} />
</ReactFlowProvider>
)
}

View File

@@ -0,0 +1,265 @@
'use client'
import { useMemo } from 'react'
import { cloneDeep } from 'lodash'
import ReactFlow, {
Background,
ConnectionLineType,
type Edge,
type EdgeTypes,
type Node,
type NodeTypes,
ReactFlowProvider,
} from 'reactflow'
import 'reactflow/dist/style.css'
import { createLogger } from '@/lib/logs/console-logger'
import { cn } from '@/lib/utils'
import { LoopNodeComponent } from '@/app/w/[id]/components/loop-node/loop-node'
import { ParallelNodeComponent } from '@/app/w/[id]/components/parallel-node/parallel-node'
import { WorkflowBlock } from '@/app/w/[id]/components/workflow-block/workflow-block'
import { WorkflowEdge } from '@/app/w/[id]/components/workflow-edge/workflow-edge'
import { getBlock } from '@/blocks'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('WorkflowPreview')
interface WorkflowPreviewProps {
workflowState: WorkflowState
showSubBlocks?: boolean
className?: string
height?: string | number
width?: string | number
isPannable?: boolean
defaultPosition?: { x: number; y: number }
defaultZoom?: number
}
// Define node types - the components now handle preview mode internally
const nodeTypes: NodeTypes = {
workflowBlock: WorkflowBlock,
loopNode: LoopNodeComponent,
parallelNode: ParallelNodeComponent,
}
// Define edge types
const edgeTypes: EdgeTypes = {
workflowEdge: WorkflowEdge,
}
export function WorkflowPreview({
workflowState,
showSubBlocks = true,
height = '100%',
width = '100%',
isPannable = false,
defaultPosition,
defaultZoom,
}: WorkflowPreviewProps) {
const blocksStructure = useMemo(
() => ({
count: Object.keys(workflowState.blocks || {}).length,
ids: Object.keys(workflowState.blocks || {}).join(','),
}),
[workflowState.blocks]
)
const loopsStructure = useMemo(
() => ({
count: Object.keys(workflowState.loops || {}).length,
ids: Object.keys(workflowState.loops || {}).join(','),
}),
[workflowState.loops]
)
const parallelsStructure = useMemo(
() => ({
count: Object.keys(workflowState.parallels || {}).length,
ids: Object.keys(workflowState.parallels || {}).join(','),
}),
[workflowState.parallels]
)
const edgesStructure = useMemo(
() => ({
count: workflowState.edges.length,
ids: workflowState.edges.map((e) => e.id).join(','),
}),
[workflowState.edges]
)
const calculateAbsolutePosition = (
block: any,
blocks: Record<string, any>
): { x: number; y: number } => {
if (!block.data?.parentId) {
return block.position
}
const parentBlock = blocks[block.data.parentId]
if (!parentBlock) {
logger.warn(`Parent block not found for child block: ${block.id}`)
return block.position
}
const parentAbsolutePosition = calculateAbsolutePosition(parentBlock, blocks)
return {
x: parentAbsolutePosition.x + block.position.x,
y: parentAbsolutePosition.y + block.position.y,
}
}
const nodes: Node[] = useMemo(() => {
const nodeArray: Node[] = []
Object.entries(workflowState.blocks).forEach(([blockId, block]) => {
if (!block || !block.type) {
logger.warn(`Skipping invalid block: ${blockId}`)
return
}
const absolutePosition = calculateAbsolutePosition(block, workflowState.blocks)
if (block.type === 'loop') {
nodeArray.push({
id: block.id,
type: 'loopNode',
position: absolutePosition,
parentId: block.data?.parentId,
extent: block.data?.extent || undefined,
draggable: false,
data: {
...block.data,
width: block.data?.width || 500,
height: block.data?.height || 300,
state: 'valid',
isPreview: true,
},
})
return
}
if (block.type === 'parallel') {
nodeArray.push({
id: block.id,
type: 'parallelNode',
position: absolutePosition,
parentId: block.data?.parentId,
extent: block.data?.extent || undefined,
draggable: false,
data: {
...block.data,
width: block.data?.width || 500,
height: block.data?.height || 300,
state: 'valid',
isPreview: true,
},
})
return
}
const blockConfig = getBlock(block.type)
if (!blockConfig) {
logger.error(`No configuration found for block type: ${block.type}`, { blockId })
return
}
const subBlocksClone = block.subBlocks ? cloneDeep(block.subBlocks) : {}
nodeArray.push({
id: blockId,
type: 'workflowBlock',
position: absolutePosition,
draggable: false,
data: {
type: block.type,
config: blockConfig,
name: block.name,
blockState: block,
isReadOnly: true,
isPreview: true,
subBlockValues: subBlocksClone,
},
})
if (block.type === 'loop') {
const childBlocks = Object.entries(workflowState.blocks).filter(
([_, childBlock]) => childBlock.data?.parentId === blockId
)
childBlocks.forEach(([childId, childBlock]) => {
const childConfig = getBlock(childBlock.type)
if (childConfig) {
nodeArray.push({
id: childId,
type: 'workflowBlock',
position: {
x: block.position.x + 50,
y: block.position.y + (childBlock.position?.y || 100),
},
data: {
type: childBlock.type,
config: childConfig,
name: childBlock.name,
blockState: childBlock,
showSubBlocks,
isChild: true,
parentId: blockId,
isReadOnly: true,
isPreview: true,
},
draggable: false,
})
}
})
}
})
return nodeArray
}, [blocksStructure, loopsStructure, parallelsStructure, showSubBlocks, workflowState.blocks])
const edges: Edge[] = useMemo(() => {
return workflowState.edges.map((edge) => ({
id: edge.id,
source: edge.source,
target: edge.target,
sourceHandle: edge.sourceHandle,
targetHandle: edge.targetHandle,
type: 'workflowEdge',
}))
}, [edgesStructure, workflowState.edges])
return (
<ReactFlowProvider>
<div style={{ height, width }} className={cn('preview-mode')}>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
connectionLineType={ConnectionLineType.SmoothStep}
fitView
panOnScroll={false}
panOnDrag={isPannable}
zoomOnScroll={false}
draggable={false}
defaultViewport={{
x: defaultPosition?.x ?? 0,
y: defaultPosition?.y ?? 0,
zoom: defaultZoom ?? 1,
}}
minZoom={0.1}
maxZoom={2}
proOptions={{ hideAttribution: true }}
elementsSelectable={false}
nodesDraggable={false}
nodesConnectable={false}
>
<Background />
</ReactFlow>
</div>
</ReactFlowProvider>
)
}

View File

@@ -4,7 +4,7 @@ import { useEffect, useState } from 'react'
import { Eye } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card'
import { WorkflowPreview } from '@/app/w/components/workflow-preview/generic-workflow-preview'
import { WorkflowPreview } from '@/app/w/components/workflow-preview/workflow-preview'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { Workflow } from '../marketplace'
@@ -96,7 +96,13 @@ export function WorkflowCard({ workflow, onHover }: WorkflowCardProps) {
// Interactive Preview
<div className='absolute inset-0 flex items-center justify-center'>
<div className='h-full w-full scale-[0.9] transform-gpu'>
<WorkflowPreview workflowState={workflow.workflowState} />
<WorkflowPreview
workflowState={{
...workflow.workflowState,
parallels: workflow.workflowState.parallels || {},
loops: workflow.workflowState.loops || {},
}}
/>
</div>
</div>
) : workflow.thumbnail ? (

View File

@@ -30,6 +30,7 @@ export interface Workflow {
targetHandle?: string
}>
loops: Record<string, any>
parallels?: Record<string, any>
}
}
@@ -54,6 +55,7 @@ export interface MarketplaceWorkflow {
targetHandle?: string
}>
loops: Record<string, any>
parallels?: Record<string, any>
}
}

View File

@@ -303,7 +303,7 @@ describe('ConditionBlockHandler', () => {
.mockReturnValueOnce('context.value === 99')
await expect(handler.execute(mockBlock, inputs, mockContext)).rejects.toThrow(
`No matching path found for condition block ${mockBlock.id}, and no 'else' block exists.`
`No matching path found for condition block "${mockBlock.metadata?.name}", and no 'else' block exists.`
)
})

View File

@@ -173,12 +173,12 @@ export class ConditionBlockHandler implements BlockHandler {
selectedCondition = elseCondition
} else {
throw new Error(
`No path found for condition block ${block.id}, and 'else' connection missing.`
`No path found for condition block "${block.metadata?.name}", and 'else' connection missing.`
)
}
} else {
throw new Error(
`No matching path found for condition block ${block.id}, and no 'else' block exists.`
`No matching path found for condition block "${block.metadata?.name}", and no 'else' block exists.`
)
}
}

View File

@@ -1,5 +1,6 @@
import type { Edge } from 'reactflow'
import type { BlockOutput, SubBlockType } from '@/blocks/types'
import type { DeploymentStatus } from '../registry/types'
export interface Position {
x: number
@@ -69,13 +70,6 @@ export interface Parallel {
distribution?: any[] | Record<string, any> | string // Items or expression
}
export interface DeploymentStatus {
isDeployed: boolean
deployedAt?: Date
apiKey?: string
needsRedeployment?: boolean
}
export interface WorkflowState {
blocks: Record<string, BlockState>
edges: Edge[]