mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
fix(workflow-preview): fixed the workflow preview to pull directly from the state in DB
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
355
apps/sim/app/w/[id]/components/control-bar/control-bar.test.ts
Normal file
355
apps/sim/app/w/[id]/components/control-bar/control-bar.test.ts
Normal 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))
|
||||
})
|
||||
})
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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() ?? ''}
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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' />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' />
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
265
apps/sim/app/w/components/workflow-preview/workflow-preview.tsx
Normal file
265
apps/sim/app/w/components/workflow-preview/workflow-preview.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.`
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[]
|
||||
|
||||
Reference in New Issue
Block a user