From 15b0879515e2338cdc852524232627acc8abeea2 Mon Sep 17 00:00:00 2001 From: Emir Karabeg Date: Sat, 24 Jan 2026 02:26:05 -0800 Subject: [PATCH] fix(preview): subblock values --- .../w/components/preview/components/block.tsx | 476 ++++++++++++++++-- .../w/components/preview/preview.tsx | 1 + 2 files changed, 423 insertions(+), 54 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/block.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/block.tsx index 83058b04d8..a56e881de2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/block.tsx @@ -3,11 +3,26 @@ import { memo, useMemo } from 'react' import { Handle, type NodeProps, Position } from 'reactflow' import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions' +import { + buildCanonicalIndex, + evaluateSubBlockCondition, + isSubBlockFeatureEnabled, + isSubBlockVisibleForMode, +} from '@/lib/workflows/subblocks/visibility' +import { getDisplayValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block' import { getBlock } from '@/blocks' +import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types' +import { useVariablesStore } from '@/stores/panel/variables/store' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' /** Execution status for blocks in preview mode */ type ExecutionStatus = 'success' | 'error' | 'not-executed' +/** Subblock value structure matching workflow state */ +interface SubBlockValueEntry { + value: unknown +} + interface WorkflowPreviewBlockData { type: string name: string @@ -18,12 +33,220 @@ interface WorkflowPreviewBlockData { isPreviewSelected?: boolean /** Execution status for highlighting error/success states */ executionStatus?: ExecutionStatus + /** Subblock values from the workflow state */ + subBlockValues?: Record +} + +/** + * Extracts the raw value from a subblock value entry. + * Handles both wrapped ({ value: ... }) and unwrapped formats. + */ +function extractValue(entry: SubBlockValueEntry | unknown): unknown { + if (entry && typeof entry === 'object' && 'value' in entry) { + return (entry as SubBlockValueEntry).value + } + return entry +} + +interface SubBlockRowProps { + title: string + value?: string + subBlock?: SubBlockConfig + rawValue?: unknown +} + +/** + * Resolves dropdown/combobox value to its display label. + * Returns null if not a dropdown/combobox or no matching option found. + */ +function resolveDropdownLabel( + subBlock: SubBlockConfig | undefined, + rawValue: unknown +): string | null { + if (!subBlock || (subBlock.type !== 'dropdown' && subBlock.type !== 'combobox')) return null + if (!rawValue || typeof rawValue !== 'string') return null + + const options = typeof subBlock.options === 'function' ? subBlock.options() : subBlock.options + if (!options) return null + + const option = options.find((opt) => + typeof opt === 'string' ? opt === rawValue : opt.id === rawValue + ) + + if (!option) return null + return typeof option === 'string' ? option : option.label +} + +/** + * Resolves workflow ID to workflow name using the workflow registry. + * Uses synchronous store access to avoid hook dependencies. + */ +function resolveWorkflowName( + subBlock: SubBlockConfig | undefined, + rawValue: unknown +): string | null { + if (subBlock?.type !== 'workflow-selector') return null + if (!rawValue || typeof rawValue !== 'string') return null + + const workflowMap = useWorkflowRegistry.getState().workflows + return workflowMap[rawValue]?.name ?? null +} + +/** + * Type guard for variable assignments array + */ +function isVariableAssignmentsArray( + value: unknown +): value is Array<{ id?: string; variableId?: string; variableName?: string; value: unknown }> { + return ( + Array.isArray(value) && + value.length > 0 && + value.every( + (item) => + typeof item === 'object' && + item !== null && + ('variableName' in item || 'variableId' in item) + ) + ) +} + +/** + * Resolves variables-input to display names. + * Uses synchronous store access to avoid hook dependencies. + */ +function resolveVariablesDisplay( + subBlock: SubBlockConfig | undefined, + rawValue: unknown +): string | null { + if (subBlock?.type !== 'variables-input') return null + if (!isVariableAssignmentsArray(rawValue)) return null + + const variables = useVariablesStore.getState().variables + const variablesArray = Object.values(variables) + + const names = rawValue + .map((a) => { + if (a.variableId) { + const variable = variablesArray.find((v) => v.id === a.variableId) + return variable?.name + } + if (a.variableName) return a.variableName + return null + }) + .filter((name): name is string => !!name) + + if (names.length === 0) return null + if (names.length === 1) return names[0] + if (names.length === 2) return `${names[0]}, ${names[1]}` + return `${names[0]}, ${names[1]} +${names.length - 2}` +} + +/** + * Resolves tool-input to display names. + * Resolves built-in tools from block registry (no API needed). + */ +function resolveToolsDisplay( + subBlock: SubBlockConfig | undefined, + rawValue: unknown +): string | null { + if (subBlock?.type !== 'tool-input') return null + if (!Array.isArray(rawValue) || rawValue.length === 0) return null + + const toolNames = rawValue + .map((tool: unknown) => { + if (!tool || typeof tool !== 'object') return null + const t = tool as Record + + // Priority 1: Use tool.title if already populated + if (t.title && typeof t.title === 'string') return t.title + + // Priority 2: Extract from inline schema (legacy format) + const schema = t.schema as Record | undefined + if (schema?.function && typeof schema.function === 'object') { + const fn = schema.function as Record + if (fn.name && typeof fn.name === 'string') return fn.name + } + + // Priority 3: Extract from OpenAI function format + const fn = t.function as Record | undefined + if (fn?.name && typeof fn.name === 'string') return fn.name + + // Priority 4: Resolve built-in tool blocks from registry + if ( + typeof t.type === 'string' && + t.type !== 'custom-tool' && + t.type !== 'mcp' && + t.type !== 'workflow' && + t.type !== 'workflow_input' + ) { + const blockConfig = getBlock(t.type) + if (blockConfig?.name) return blockConfig.name + } + + return null + }) + .filter((name): name is string => !!name) + + if (toolNames.length === 0) return null + if (toolNames.length === 1) return toolNames[0] + if (toolNames.length === 2) return `${toolNames[0]}, ${toolNames[1]}` + return `${toolNames[0]}, ${toolNames[1]} +${toolNames.length - 2}` +} + +/** + * Renders a single subblock row with title and optional value. + * Matches the SubBlockRow component in WorkflowBlock. + * - Masks password fields with bullets + * - Resolves dropdown/combobox labels + * - Resolves workflow names from registry + * - Resolves variable names from store + * - Resolves tool names from block registry + * - Shows '-' for other selector types that need hydration + */ +function SubBlockRow({ title, value, subBlock, rawValue }: SubBlockRowProps) { + // Mask password fields + const isPasswordField = subBlock?.password === true + const maskedValue = isPasswordField && value && value !== '-' ? '•••' : null + + // Resolve various display names (synchronous access, matching WorkflowBlock priority) + const dropdownLabel = resolveDropdownLabel(subBlock, rawValue) + const variablesDisplay = resolveVariablesDisplay(subBlock, rawValue) + const toolsDisplay = resolveToolsDisplay(subBlock, rawValue) + const workflowName = resolveWorkflowName(subBlock, rawValue) + + // Check if this is a selector type that needs hydration (show '-' for raw IDs) + const isSelectorType = subBlock?.type && SELECTOR_TYPES_HYDRATION_REQUIRED.includes(subBlock.type) + + // Compute final display value matching WorkflowBlock logic + // Priority order matches WorkflowBlock: masked > hydrated names > selector fallback > raw value + const hydratedName = dropdownLabel || variablesDisplay || toolsDisplay || workflowName + const displayValue = maskedValue || hydratedName || (isSelectorType && value ? '-' : value) + + return ( +
+ + {title} + + {displayValue !== undefined && ( + + {displayValue} + + )} +
+ ) } /** * Preview block component for workflow visualization. - * Renders block header, subblocks skeleton, and handles without + * Renders block header, subblock values, and handles without * hooks, store subscriptions, or interactive features. + * Matches the visual structure of WorkflowBlock exactly. */ function WorkflowPreviewBlockInner({ data }: NodeProps) { const { @@ -34,21 +257,111 @@ function WorkflowPreviewBlockInner({ data }: NodeProps enabled = true, isPreviewSelected = false, executionStatus, + subBlockValues, } = data const blockConfig = getBlock(type) + const canonicalIndex = useMemo( + () => buildCanonicalIndex(blockConfig?.subBlocks || []), + [blockConfig?.subBlocks] + ) + + const rawValues = useMemo(() => { + if (!subBlockValues) return {} + return Object.entries(subBlockValues).reduce>((acc, [key, entry]) => { + acc[key] = extractValue(entry) + return acc + }, {}) + }, [subBlockValues]) + const visibleSubBlocks = useMemo(() => { if (!blockConfig?.subBlocks) return [] + const isStarterOrTrigger = + blockConfig.category === 'triggers' || type === 'starter' || isTrigger + return blockConfig.subBlocks.filter((subBlock) => { if (subBlock.hidden) return false if (subBlock.hideFromPreview) return false - if (subBlock.mode === 'trigger' && blockConfig.category !== 'triggers') return false - if (subBlock.mode === 'advanced') return false - return true + if (!isSubBlockFeatureEnabled(subBlock)) return false + + // Handle trigger mode visibility + if (subBlock.mode === 'trigger' && !isStarterOrTrigger) return false + + // Check advanced mode visibility + if (!isSubBlockVisibleForMode(subBlock, false, canonicalIndex, rawValues, undefined)) { + return false + } + + // Check condition visibility + if (!subBlock.condition) return true + return evaluateSubBlockCondition(subBlock.condition, rawValues) }) - }, [blockConfig?.subBlocks]) + }, [blockConfig?.subBlocks, blockConfig?.category, type, isTrigger, canonicalIndex, rawValues]) + + /** + * Compute condition rows for condition blocks + */ + const conditionRows = useMemo(() => { + if (type !== 'condition') return [] + + const conditionsValue = rawValues.conditions + const raw = typeof conditionsValue === 'string' ? conditionsValue : undefined + + try { + if (raw) { + const parsed = JSON.parse(raw) as unknown + if (Array.isArray(parsed)) { + return parsed.map((item: unknown, index: number) => { + const conditionItem = item as { id?: string; value?: unknown } + const title = index === 0 ? 'if' : index === parsed.length - 1 ? 'else' : 'else if' + return { + id: conditionItem?.id ?? `cond-${index}`, + title, + value: typeof conditionItem?.value === 'string' ? conditionItem.value : '', + } + }) + } + } + } catch { + // Failed to parse, use fallback + } + + return [ + { id: 'if', title: 'if', value: '' }, + { id: 'else', title: 'else', value: '' }, + ] + }, [type, rawValues]) + + /** + * Compute router rows for router_v2 blocks + */ + const routerRows = useMemo(() => { + if (type !== 'router_v2') return [] + + const routesValue = rawValues.routes + const raw = typeof routesValue === 'string' ? routesValue : undefined + + try { + if (raw) { + const parsed = JSON.parse(raw) as unknown + if (Array.isArray(parsed)) { + return parsed.map((item: unknown, index: number) => { + const routeItem = item as { id?: string; value?: string } + return { + id: routeItem?.id ?? `route${index + 1}`, + value: routeItem?.value ?? '', + } + }) + } + } + } catch { + // Failed to parse, use fallback + } + + return [{ id: 'route1', value: '' }] + }, [type, rawValues]) if (!blockConfig) { return null @@ -57,8 +370,14 @@ function WorkflowPreviewBlockInner({ data }: NodeProps const IconComponent = blockConfig.icon const isStarterOrTrigger = blockConfig.category === 'triggers' || type === 'starter' || isTrigger + const shouldShowDefaultHandles = !isStarterOrTrigger const hasSubBlocks = visibleSubBlocks.length > 0 - const showErrorRow = !isStarterOrTrigger + const hasContentBelowHeader = + type === 'condition' + ? conditionRows.length > 0 || shouldShowDefaultHandles + : type === 'router_v2' + ? routerRows.length > 0 || shouldShowDefaultHandles + : hasSubBlocks || shouldShowDefaultHandles const horizontalHandleClass = '!border-none !bg-[var(--surface-7)] !h-5 !w-[7px] !rounded-[2px]' const verticalHandleClass = '!border-none !bg-[var(--surface-7)] !h-[7px] !w-5 !rounded-[2px]' @@ -67,7 +386,7 @@ function WorkflowPreviewBlockInner({ data }: NodeProps const hasSuccess = executionStatus === 'success' return ( -
+
{/* Selection ring overlay (takes priority over execution rings) */} {isPreviewSelected && (
@@ -82,7 +401,7 @@ function WorkflowPreviewBlockInner({ data }: NodeProps )} {/* Target handle - not shown for triggers/starters */} - {!isStarterOrTrigger && ( + {shouldShowDefaultHandles && ( /> )} - {/* Header */} + {/* Header - matches WorkflowBlock structure */}
-
- +
+
+ +
+ + {name} +
- - {name} -
- {/* Subblocks skeleton */} - {(hasSubBlocks || showErrorRow) && ( + {/* Content area with subblocks */} + {hasContentBelowHeader && (
- {visibleSubBlocks.slice(0, 4).map((subBlock) => ( -
- - {subBlock.title ?? subBlock.id} - - - -
- ))} - {visibleSubBlocks.length > 4 && ( -
- - +{visibleSubBlocks.length - 4} more - -
- )} - {showErrorRow && ( -
- - error - -
+ {type === 'condition' ? ( + // Condition block: render condition rows + conditionRows.map((cond) => ( + + )) + ) : type === 'router_v2' ? ( + // Router block: render context + route rows + <> + + {routerRows.map((route, index) => ( + + ))} + + ) : ( + // Standard blocks: render visible subblocks + visibleSubBlocks.map((subBlock) => { + const rawValue = rawValues[subBlock.id] + return ( + + ) + }) )} + {/* Error row for non-trigger blocks */} + {shouldShowDefaultHandles && }
)} @@ -162,16 +499,47 @@ function shouldSkipPreviewBlockRender( prevProps: NodeProps, nextProps: NodeProps ): boolean { - return ( - prevProps.id === nextProps.id && - prevProps.data.type === nextProps.data.type && - prevProps.data.name === nextProps.data.name && - prevProps.data.isTrigger === nextProps.data.isTrigger && - prevProps.data.horizontalHandles === nextProps.data.horizontalHandles && - prevProps.data.enabled === nextProps.data.enabled && - prevProps.data.isPreviewSelected === nextProps.data.isPreviewSelected && - prevProps.data.executionStatus === nextProps.data.executionStatus - ) + // Check primitive props first (fast path) + if ( + prevProps.id !== nextProps.id || + prevProps.data.type !== nextProps.data.type || + prevProps.data.name !== nextProps.data.name || + prevProps.data.isTrigger !== nextProps.data.isTrigger || + prevProps.data.horizontalHandles !== nextProps.data.horizontalHandles || + prevProps.data.enabled !== nextProps.data.enabled || + prevProps.data.isPreviewSelected !== nextProps.data.isPreviewSelected || + prevProps.data.executionStatus !== nextProps.data.executionStatus + ) { + return false + } + + // Compare subBlockValues by reference first + const prevValues = prevProps.data.subBlockValues + const nextValues = nextProps.data.subBlockValues + + if (prevValues === nextValues) { + return true + } + + if (!prevValues || !nextValues) { + return false + } + + // Shallow compare keys and values + const prevKeys = Object.keys(prevValues) + const nextKeys = Object.keys(nextValues) + + if (prevKeys.length !== nextKeys.length) { + return false + } + + for (const key of prevKeys) { + if (prevValues[key] !== nextValues[key]) { + return false + } + } + + return true } export const WorkflowPreviewBlock = memo(WorkflowPreviewBlockInner, shouldSkipPreviewBlockRender) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx index 0f844300fd..a9d56c7943 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx @@ -347,6 +347,7 @@ export function WorkflowPreview({ enabled: block.enabled ?? true, isPreviewSelected: isSelected, executionStatus, + subBlockValues: block.subBlocks, }, }) })