fix: working on subblock rendering

This commit is contained in:
Adam Gough
2025-05-16 02:27:12 -07:00
parent dc1433eecf
commit 0f2dd48887
9 changed files with 332 additions and 107 deletions

View File

@@ -1,5 +1,5 @@
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'
@@ -11,6 +11,12 @@ 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,7 +27,8 @@ 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 the workflow's deployed state
@@ -36,7 +43,8 @@ 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]
@@ -44,18 +52,21 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
// 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,
message: 'Workflow is not deployed or has no deployed state',
})
return addNoCacheHeaders(response)
}
logger.info(`[${requestId}] Successfully retrieved deployed state for: ${id}`)
return createSuccessResponse({
const response = createSuccessResponse({
deployedState: workflowData.deployedState,
})
return addNoCacheHeaders(response)
} catch (error: any) {
logger.error(`[${requestId}] Error fetching deployed state: ${id}`, error)
return createErrorResponse(error.message || 'Failed to fetch deployed state', 500)
const response = createErrorResponse(error.message || 'Failed to fetch deployed state', 500)
return addNoCacheHeaders(response)
}
}

View File

@@ -38,6 +38,7 @@ interface DeployModalProps {
setNeedsRedeployment: (value: boolean) => void
deployedState: any
isLoadingDeployedState: boolean
refetchDeployedState: () => Promise<void>
}
interface ApiKey {
@@ -73,6 +74,7 @@ export function DeployModal({
setNeedsRedeployment,
deployedState,
isLoadingDeployedState,
refetchDeployedState,
}: DeployModalProps) {
// Store hooks
const { addNotification } = useNotificationStore()
@@ -310,6 +312,10 @@ export function DeployModal({
setDeploymentInfo(newDeploymentInfo)
// Fetch the updated deployed state after deployment
logger.info('Deployment successful, fetching initial deployed state')
await refetchDeployedState()
// No notification on successful deploy
} catch (error: any) {
logger.error('Error deploying workflow:', { error })
@@ -399,6 +405,10 @@ export function DeployModal({
useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(workflowId, false)
}
// Fetch the updated deployed state after redeployment
logger.info('Redeployment successful, fetching updated deployed state')
await refetchDeployedState()
// Add a success notification
addNotification('info', 'Workflow successfully redeployed', workflowId)
} catch (error: any) {

View File

@@ -4,7 +4,7 @@ import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
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'
interface DeployedWorkflowCardProps {
currentWorkflowState?: {
@@ -27,6 +27,7 @@ export function DeployedWorkflowCard({
}: DeployedWorkflowCardProps) {
const [showingDeployed, setShowingDeployed] = useState(true)
const workflowToShow = showingDeployed ? deployedWorkflowState : currentWorkflowState
console.log('workflowToShow', workflowToShow)
return (
<Card className={cn('relative overflow-hidden', className)}>

View File

@@ -17,6 +17,7 @@ interface DeploymentControlsProps {
setNeedsRedeployment: (value: boolean) => void
deployedState: any
isLoadingDeployedState: boolean
refetchDeployedState: () => Promise<void>
}
export function DeploymentControls({
@@ -25,6 +26,7 @@ export function DeploymentControls({
setNeedsRedeployment,
deployedState,
isLoadingDeployedState,
refetchDeployedState,
}: DeploymentControlsProps) {
// Use workflow-specific deployment status
const deploymentStatus = useWorkflowRegistry((state) =>
@@ -106,6 +108,7 @@ export function DeploymentControls({
setNeedsRedeployment={setNeedsRedeployment}
deployedState={deployedState}
isLoadingDeployedState={isLoadingDeployedState}
refetchDeployedState={refetchDeployedState}
/>
</>
)

View File

@@ -198,6 +198,260 @@ export function ControlBar() {
return () => clearInterval(interval)
}, [])
// 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
// Function to check if redeployment is needed
const checkForChanges = async () => {
// Skip if we're already showing needsRedeployment
if (needsRedeployment) return
// Reset the pending changes counter
pendingChanges = 0
lastCheckTime = Date.now()
try {
// Get the deployed state from the API
const response = await fetch(`/api/workflows/${activeWorkflowId}/status`)
if (response.ok) {
const data = await response.json()
// If the API says we need redeployment, update our state and the store
if (data.needsRedeployment) {
setNeedsRedeployment(true)
// Also update the store state so other components can access this flag
useWorkflowStore.getState().setNeedsRedeploymentFlag(true)
}
}
} catch (error) {
logger.error('Failed to check workflow change status:', { error })
}
}
// Debounced check function
const debouncedCheck = () => {
// 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
if (pendingChanges > 0) {
checkForChanges()
}
}, adjustedDelay)
} else {
// Standard debounce delay if we haven't checked recently
debounceTimer = setTimeout(() => {
// Only check if we have pending changes
if (pendingChanges > 0) {
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
if (!activeWorkflowId || !isDeployed || needsRedeployment) return
// Only trigger when there is an update to the current workflow's subblocks
const workflowSubBlocks = state.workflowValues[activeWorkflowId]
if (workflowSubBlocks && Object.keys(workflowSubBlocks).length > 0) {
debouncedCheck()
}
})
return () => {
if (debounceTimer) {
clearTimeout(debounceTimer)
}
workflowUnsubscribe()
subBlockUnsubscribe()
}
}, [activeWorkflowId, isDeployed, needsRedeployment])
// Check deployment and publication status on mount or when activeWorkflowId changes
useEffect(() => {
async function checkStatus() {
if (!activeWorkflowId) return
// Skip API call in localStorage mode
if (
typeof window !== 'undefined' &&
(localStorage.getItem('USE_LOCAL_STORAGE') === 'true' ||
process.env.NEXT_PUBLIC_USE_LOCAL_STORAGE === 'true' ||
process.env.NEXT_PUBLIC_DISABLE_DB_SYNC === 'true')
) {
// For localStorage mode, we already have the status in the workflow store
// Nothing more to do as the useWorkflowStore already has this information
return
}
try {
const response = await fetch(`/api/workflows/${activeWorkflowId}/status`)
if (response.ok) {
const data = await response.json()
// Update the store with the status from the API
setDeploymentStatus(
data.isDeployed,
data.deployedAt ? new Date(data.deployedAt) : undefined
)
setNeedsRedeployment(data.needsRedeployment)
useWorkflowStore.getState().setNeedsRedeploymentFlag(data.needsRedeployment)
}
} catch (error) {
logger.error('Failed to check workflow status:', { error })
}
}
checkStatus()
}, [activeWorkflowId, setDeploymentStatus])
// Add a function to explicitly fetch deployed state that can be called after redeployment
const refetchDeployedState = async () => {
if (!activeWorkflowId || !isDeployed) {
setDeployedState(null)
return;
}
try {
setIsLoadingDeployedState(true);
logger.info(`[CENTRAL] Explicitly refetching deployed state for workflow: ${activeWorkflowId}`);
const response = await fetch(`/api/workflows/${activeWorkflowId}/deployed`);
if (!response.ok) {
throw new Error(`Failed to fetch deployed state: ${response.status}`);
}
const data = await response.json();
if (data.deployedState) {
logger.info('Successfully refetched deployed state from DB after redeployment');
// Create a deep clone to ensure no reference sharing with current state
const deepClonedState = JSON.parse(JSON.stringify(data.deployedState));
logger.info('deepClonedState', deepClonedState)
setDeployedState(deepClonedState);
} else {
logger.warn('No deployed state found in the database after refetch');
setDeployedState(null);
}
} catch (error) {
logger.error('Error refetching deployed state:', error);
setDeployedState(null);
} finally {
setIsLoadingDeployedState(false);
}
};
// Fetch deployed state when the workflow ID changes or deployment status changes
useEffect(() => {
async function fetchDeployedState() {
if (!activeWorkflowId || !isDeployed) {
setDeployedState(null)
return
}
try {
setIsLoadingDeployedState(true)
logger.info(`[CENTRAL] Fetching deployed state for workflow: ${activeWorkflowId} (Control Bar - Single Source of Truth)`)
const response = await fetch(`/api/workflows/${activeWorkflowId}/deployed`)
if (!response.ok) {
throw new Error(`Failed to fetch deployed state: ${response.status}`)
}
const data = await response.json()
if (data.deployedState) {
logger.info('Successfully fetched deployed state from DB - This is the only place that should fetch deployed state')
// Create a deep clone to ensure no reference sharing with current state
const deepClonedState = JSON.parse(JSON.stringify(data.deployedState))
logger.info('deepClonedState', deepClonedState)
setDeployedState(deepClonedState)
} else {
logger.warn('No deployed state found in the database')
setDeployedState(null)
}
} catch (error) {
logger.error('Error fetching deployed state:', error)
setDeployedState(null)
} finally {
setIsLoadingDeployedState(false)
}
}
fetchDeployedState()
}, [activeWorkflowId, isDeployed])
// 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) {
setNeedsRedeployment(false)
useWorkflowStore.getState().setNeedsRedeploymentFlag(false)
// When a workflow is newly deployed, we need to fetch the deployed state
if (activeWorkflowId && deployedState === null) {
// We'll fetch the deployed state in the other useEffect
}
} else {
// If workflow is undeployed, clear the deployed state
setDeployedState(null)
}
}, [isDeployed, 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) {
setNeedsRedeployment(false)
useWorkflowStore.getState().setNeedsRedeploymentFlag(false)
}
}, [isDeployed])
// Add a listener for the needsRedeployment flag in the workflow store
useEffect(() => {
const unsubscribe = useWorkflowStore.subscribe((state) => {
// Update local state when the store flag changes
if (state.needsRedeployment !== undefined) {
setNeedsRedeployment(state.needsRedeployment)
}
})
return () => unsubscribe()
}, [])
// Add a manual method to update the deployment status and clear the needsRedeployment flag
const updateDeploymentStatusAndClearFlag = (isDeployed: boolean, deployedAt?: Date) => {
setDeploymentStatus(isDeployed, deployedAt)
setNeedsRedeployment(false)
useWorkflowStore.getState().setNeedsRedeploymentFlag(false)
}
// Update existing API notifications when needsRedeployment changes
useEffect(() => {
if (!activeWorkflowId) return
@@ -540,6 +794,7 @@ export function ControlBar() {
setNeedsRedeployment={setNeedsRedeployment}
deployedState={deployedState}
isLoadingDeployedState={isLoadingDeployedState}
refetchDeployedState={refetchDeployedState}
/>
)
@@ -1001,63 +1256,6 @@ export function ControlBar() {
</div>
)
// Fetch deployed state when the workflow ID changes or deployment status changes
useEffect(() => {
async function fetchDeployedState() {
if (!activeWorkflowId || !isDeployed) {
setDeployedState(null)
return
}
try {
setIsLoadingDeployedState(true)
logger.info(`[CENTRAL] Fetching deployed state for workflow: ${activeWorkflowId} (Control Bar - Single Source of Truth)`)
const response = await fetch(`/api/workflows/${activeWorkflowId}/deployed`)
if (!response.ok) {
throw new Error(`Failed to fetch deployed state: ${response.status}`)
}
const data = await response.json()
if (data.deployedState) {
logger.info('Successfully fetched deployed state from DB - This is the only place that should fetch deployed state')
// Create a deep clone to ensure no reference sharing with current state
const deepClonedState = JSON.parse(JSON.stringify(data.deployedState))
setDeployedState(deepClonedState)
} else {
logger.warn('No deployed state found in the database')
setDeployedState(null)
}
} catch (error) {
logger.error('Error fetching deployed state:', error)
setDeployedState(null)
} finally {
setIsLoadingDeployedState(false)
}
}
fetchDeployedState()
}, [activeWorkflowId, isDeployed])
// 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) {
setNeedsRedeployment(false)
useWorkflowStore.getState().setNeedsRedeploymentFlag(false)
// When a workflow is newly deployed, we need to fetch the deployed state
if (activeWorkflowId && deployedState === null) {
// We'll fetch the deployed state in the other useEffect
}
} else {
// If workflow is undeployed, clear the deployed state
setDeployedState(null)
}
}, [isDeployed, activeWorkflowId])
return (
<div className='flex h-16 w-full items-center justify-between border-b bg-background'>
{/* Left Section - Workflow Info */}

View File

@@ -42,7 +42,10 @@ 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 +63,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 +96,11 @@ export function OutputSelect({
if (validOutputs.length === 1) {
const output = workflowOutputs.find((o) => o.id === validOutputs[0])
if (output) {
return `${output.blockName.replace(/\s+/g, '').toLowerCase()}.${output.path}`
// Add defensive check for output.blockName
const blockNameText = output.blockName && typeof output.blockName === 'string'
? output.blockName.replace(/\s+/g, '').toLowerCase()
: `block-${output.blockId}`;
return `${blockNameText}.${output.path}`
}
return placeholder
}

View File

@@ -39,8 +39,6 @@ export function SubBlock({ blockId, config, isConnecting }: SubBlockProps) {
e.stopPropagation()
}
const { getValue } = useSubBlockStore()
const isFieldRequired = () => {
const blockType = useWorkflowStore.getState().blocks[blockId]?.type
if (!blockType) return false

View File

@@ -1,6 +1,6 @@
'use client'
import { useMemo } from 'react'
import { useMemo, useEffect } from 'react'
import ReactFlow, {
Background,
ConnectionLineType,
@@ -27,7 +27,6 @@ import { WorkflowEdge } from '@/app/w/[id]/components/workflow-edge/workflow-edg
// import { createLoopNode } from '@/app/w/[id]/components/workflow-loop/workflow-loop'
import { getBlock } from '@/blocks'
import type { SubBlockConfig } from '@/blocks/types'
import { useEffect } from 'react'
const logger = createLogger('WorkflowPreview')
@@ -68,7 +67,11 @@ const edgeTypes: EdgeTypes = {
workflowEdge: WorkflowEdge,
}
function WorkflowPreviewContent({
// The subblocks should be getting passed from the state and not the subBlockStore.
// Create optional parameter boolan isPreview to pass in the block state to know how to render
// the subblocks
export function WorkflowPreview({
workflowState,
showSubBlocks = true,
className,
@@ -174,41 +177,35 @@ function WorkflowPreviewContent({
logger.info('Rendering workflow state', { workflowState })
}, [workflowState])
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 }}
elementsSelectable={false}
nodesDraggable={false}
nodesConnectable={false}
>
<Background />
</ReactFlow>
</div>
)
}
export function WorkflowPreview(props: WorkflowPreviewProps) {
return (
<ReactFlowProvider>
<WorkflowPreviewContent {...props} />
<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 }}
elementsSelectable={false}
nodesDraggable={false}
nodesConnectable={false}
>
<Background />
</ReactFlow>
</div>
</ReactFlowProvider>
)
}

View File

@@ -4,9 +4,9 @@ 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 { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { Workflow } from '../marketplace'
import { WorkflowPreview } from '@/app/w/components/workflow-preview/workflow-preview'
import { Workflow } from '../marketplace'
/**
* WorkflowCardProps interface - defines the properties for the WorkflowCard component