diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx index df248f3b392..0976cdde23d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx @@ -1,10 +1,9 @@ import { memo, useCallback, useEffect, useMemo, useRef } from 'react' -import { Badge, cn, handleKeyboardActivation, Tooltip } from '@sim/emcn' import { createLogger } from '@sim/logger' -import { HANDLE_POSITIONS } from '@sim/workflow-renderer' +import { SubBlockRowView, WorkflowBlockView } from '@sim/workflow-renderer' import { isEqual } from 'es-toolkit' import { useParams } from 'next/navigation' -import { Handle, type NodeProps, Position, useUpdateNodeInternals } from 'reactflow' +import { type NodeProps, useUpdateNodeInternals } from 'reactflow' import { useStoreWithEqualityFn } from 'zustand/traditional' import { getBaseUrl } from '@/lib/core/utils/urls' import { createMcpToolId } from '@/lib/mcp/shared' @@ -371,25 +370,7 @@ const SubBlockRow = memo(function SubBlockRow({ const displayValue = maskedValue || hydratedName || (isSelectorType && value ? '-' : value) return ( -
- - {title} - - {displayValue !== undefined && ( - - {displayValue} - - )} -
+ ) }, areSubBlockRowPropsEqual) @@ -630,32 +611,6 @@ export const WorkflowBlock = memo(function WorkflowBlock({ config.category !== 'triggers' && type !== 'starter' && !displayTriggerMode const hasContentBelowHeader = subBlockRows.length > 0 || shouldShowDefaultHandles - /** - * Reusable styles and positioning for Handle components. - */ - const getHandleClasses = (position: 'left' | 'right' | 'top' | 'bottom', isError = false) => { - const baseClasses = '!z-[0] !cursor-crosshair !border-none !transition-[colors] !duration-150' - const colorClasses = isError ? '!bg-[var(--text-error)]' : '!bg-[var(--workflow-edge)]' - - const positionClasses = { - left: '!left-[-8px] !h-5 !w-[7px] !rounded-l-[2px] !rounded-r-none hover-hover:!left-[-11px] hover-hover:!w-[10px] hover-hover:!rounded-l-full', - right: - '!right-[-8px] !h-5 !w-[7px] !rounded-r-[2px] !rounded-l-none hover-hover:!right-[-11px] hover-hover:!w-[10px] hover-hover:!rounded-r-full', - top: '!top-[-8px] !h-[7px] !w-5 !rounded-t-[2px] !rounded-b-none hover-hover:!top-[-11px] hover-hover:!h-[10px] hover-hover:!rounded-t-full', - bottom: - '!bottom-[-8px] !h-[7px] !w-5 !rounded-b-[2px] !rounded-t-none hover-hover:!bottom-[-11px] hover-hover:!h-[10px] hover-hover:!rounded-b-full', - } - - return cn(baseClasses, colorClasses, positionClasses[position]) - } - - const getHandleStyle = (position: 'horizontal' | 'vertical') => { - if (position === 'horizontal') { - return { top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`, transform: 'translateY(-50%)' } - } - return { left: '50%', transform: 'translateX(-50%)' } - } - /** * Compute per-condition rows (title/value/id) for condition blocks so we can render * one row per condition statement with its own output handle. @@ -739,399 +694,136 @@ export const WorkflowBlock = memo(function WorkflowBlock({ type === 'schedule' && !isLoadingScheduleInfo && scheduleInfo !== null const isWorkflowSelector = type === 'workflow' || type === 'workflow_input' - return ( -
-
handleKeyboardActivation(event, handleClick)} - className={cn( - 'workflow-drag-handle relative z-[20] w-[250px] cursor-grab select-none rounded-lg border border-[var(--border-1)] bg-[var(--surface-2)] [&:active]:cursor-grabbing' - )} - > - {isPending && ( -
- Next Step -
- )} - - {!data.isPreview && !data.isEmbedded && ( - - )} - - {shouldShowDefaultHandles && ( - { - if (connection.source === id) return false - const edges = useWorkflowStore.getState().edges - return !wouldCreateCycle(edges, connection.source!, connection.target!) - }} + const wouldCreateConnectionCycle = (source: string, target: string) => + wouldCreateCycle(useWorkflowStore.getState().edges, source, target) + + const webhookProviderName = webhookProvider ? getProviderName(webhookProvider) : undefined + + const rows = ( + <> + {type === 'condition' ? ( + conditionRows.map((cond) => ( + + )) + ) : type === 'router_v2' ? ( + <> + - )} - -
-
-
- -
- - {name} - -
-
- {isWorkflowSelector && - childWorkflowId && - typeof childIsDeployed === 'boolean' && - (!childIsDeployed || childNeedsRedeploy) && ( - - - { - e.stopPropagation() - if (childWorkflowId && !isDeploying && userPermissions.canAdmin) { - deployChildWorkflow({ workflowId: childWorkflowId }) - } - }} - > - {isDeploying ? 'Deploying...' : !childIsDeployed ? 'undeployed' : 'redeploy'} - - - - - {!userPermissions.canAdmin - ? 'Admin permission required to deploy' - : !childIsDeployed - ? 'Click to deploy' - : 'Click to redeploy'} - - - - )} - {!isEnabled && !isLocked && disabled} - {isLocked && locked} - - {type === 'schedule' && shouldShowScheduleBadge && scheduleInfo?.isDisabled && ( - - - { - e.stopPropagation() - if (scheduleInfo?.id) { - reactivateSchedule(scheduleInfo.id) - } - }} - > - disabled - - - - Click to reactivate - - - )} - - {showWebhookIndicator && ( - - - - Webhook - - - - {webhookProvider && webhookPath ? ( - <> -

{getProviderName(webhookProvider)} Webhook

-

Path: {webhookPath}

- - ) : ( -

- This workflow is triggered by a webhook. -

- )} -
-
- )} - - {isWebhookConfigured && isWebhookDisabled && webhookId && ( - - - { - e.stopPropagation() - reactivateWebhook(webhookId) - }} - > - disabled - - - - Click to reactivate - - - )} - {/* {isActive && ( -
- - )} */} -
-
- - {hasContentBelowHeader && ( -
- {type === 'condition' ? ( - conditionRows.map((cond) => ( - - )) - ) : type === 'router_v2' ? ( - <> - - {routerRows.map((route, index) => ( + {routerRows.map((route, index) => ( + + ))} + + ) : ( + subBlockRows.map((row, rowIndex) => + row.flatMap((subBlock) => { + const rawValue = subBlockState[subBlock.id]?.value + if (subBlock.type === 'mcp-dynamic-args') { + const schema = subBlockState._toolSchema?.value as + | { properties?: Record } + | undefined + const properties = schema?.properties + if (properties && typeof properties === 'object') { + const args = (rawValue && typeof rawValue === 'object' ? rawValue : {}) as Record< + string, + unknown + > + return Object.keys(properties).map((paramName) => ( - ))} - - ) : ( - subBlockRows.map((row, rowIndex) => - row.flatMap((subBlock) => { - const rawValue = subBlockState[subBlock.id]?.value - if (subBlock.type === 'mcp-dynamic-args') { - const schema = subBlockState._toolSchema?.value as - | { properties?: Record } - | undefined - const properties = schema?.properties - if (properties && typeof properties === 'object') { - const args = ( - rawValue && typeof rawValue === 'object' ? rawValue : {} - ) as Record - return Object.keys(properties).map((paramName) => ( - - )) - } - return [] - } - return [ - , - ] - }) - ) - )} - {shouldShowDefaultHandles && } -
- )} - - {type === 'condition' && ( - <> - {conditionRows.map((cond, condIndex) => { - const topOffset = - HANDLE_POSITIONS.CONDITION_START_Y + - condIndex * HANDLE_POSITIONS.CONDITION_ROW_HEIGHT - return ( - { - if (connection.target === id) return false - const edges = useWorkflowStore.getState().edges - return !wouldCreateCycle(edges, connection.source!, connection.target!) - }} - /> - ) - })} - { - if (connection.target === id) return false - const edges = useWorkflowStore.getState().edges - return !wouldCreateCycle(edges, connection.source!, connection.target!) - }} - /> - - )} - - {type === 'router_v2' && ( - <> - {routerRows.map((route, routeIndex) => { - // +1 row offset for context row at the top - const topOffset = - HANDLE_POSITIONS.CONDITION_START_Y + - (routeIndex + 1) * HANDLE_POSITIONS.CONDITION_ROW_HEIGHT - return ( - { - if (connection.target === id) return false - const edges = useWorkflowStore.getState().edges - return !wouldCreateCycle(edges, connection.source!, connection.target!) - }} - /> - ) - })} - { - if (connection.target === id) return false - const edges = useWorkflowStore.getState().edges - return !wouldCreateCycle(edges, connection.source!, connection.target!) - }} - /> - - )} - - {type !== 'condition' && type !== 'router_v2' && type !== 'response' && ( - <> - { - if (connection.target === id) return false - const edges = useWorkflowStore.getState().edges - return !wouldCreateCycle(edges, connection.source!, connection.target!) - }} - /> + )) + } + return [] + } + return [ + , + ] + }) + ) + )} + {shouldShowDefaultHandles && } + + ) - {shouldShowDefaultHandles && ( - { - if (connection.target === id) return false - const edges = useWorkflowStore.getState().edges - return !wouldCreateCycle(edges, connection.source!, connection.target!) - }} - /> - )} - - )} - {hasRing && ( -
- )} -
-
+ return ( + { + if (childWorkflowId && !isDeploying && userPermissions.canAdmin) { + deployChildWorkflow({ workflowId: childWorkflowId }) + } + }} + shouldShowScheduleBadge={shouldShowScheduleBadge} + scheduleIsDisabled={Boolean(scheduleInfo?.isDisabled)} + onReactivateSchedule={() => { + if (scheduleInfo?.id) { + reactivateSchedule(scheduleInfo.id) + } + }} + showWebhookIndicator={showWebhookIndicator} + webhookProvider={webhookProvider} + webhookPath={webhookPath} + webhookProviderName={webhookProviderName} + isWebhookConfigured={isWebhookConfigured} + isWebhookDisabled={isWebhookDisabled} + webhookId={webhookId} + onReactivateWebhook={() => { + if (webhookId) { + reactivateWebhook(webhookId) + } + }} + onSelect={handleClick} + contentRef={contentRef} + actionBar={ + !data.isPreview && !data.isEmbedded ? ( + + ) : undefined + } + rows={rows} + /> ) }, shouldSkipBlockRender) diff --git a/packages/workflow-renderer/src/index.ts b/packages/workflow-renderer/src/index.ts index 482d9a4551f..0c6938dc112 100644 --- a/packages/workflow-renderer/src/index.ts +++ b/packages/workflow-renderer/src/index.ts @@ -7,3 +7,8 @@ export { type SubflowNodeViewProps, } from './subflow/subflow-node-view' export type { BlockRunStatus, DiffStatus, EdgeDiffStatus, EdgeRunStatus } from './types' +export { SubBlockRowView, type SubBlockRowViewProps } from './workflow-block/sub-block-row-view' +export { + WorkflowBlockView, + type WorkflowBlockViewProps, +} from './workflow-block/workflow-block-view' diff --git a/packages/workflow-renderer/src/workflow-block/sub-block-row-view.tsx b/packages/workflow-renderer/src/workflow-block/sub-block-row-view.tsx new file mode 100644 index 00000000000..01930cdcac1 --- /dev/null +++ b/packages/workflow-renderer/src/workflow-block/sub-block-row-view.tsx @@ -0,0 +1,44 @@ +import { cn } from '@sim/emcn' + +/** + * Props for the pure subblock summary row. The container resolves the value — + * including all selector-name hydration (credentials, knowledge bases, tables, + * MCP servers/tools, sub-workflows, skills, …) — and passes only the final + * strings, so this view carries no store, query, or registry coupling. + */ +export interface SubBlockRowViewProps { + /** Subblock label, rendered capitalized on the left. */ + title: string + /** Resolved display value on the right; `undefined` hides the value span. */ + displayValue?: string + /** Render the value in a monospace font (e.g. filter expressions). */ + isMonospace?: boolean +} + +/** + * Pure renderer for a collapsed block's subblock summary row: a capitalized + * title and its resolved display value. + */ +export function SubBlockRowView({ title, displayValue, isMonospace }: SubBlockRowViewProps) { + return ( +
+ + {title} + + {displayValue !== undefined && ( + + {displayValue} + + )} +
+ ) +} diff --git a/packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx b/packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx new file mode 100644 index 00000000000..e56254322bb --- /dev/null +++ b/packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx @@ -0,0 +1,465 @@ +import type { ComponentType, ReactNode, Ref } from 'react' +import { Badge, cn, handleKeyboardActivation, Tooltip } from '@sim/emcn' +import { Handle, Position } from 'reactflow' +import { HANDLE_POSITIONS } from '../dimensions' +import type { BlockRunStatus } from '../types' + +/** + * Reusable styles and positioning for Handle components. + */ +const getHandleClasses = (position: 'left' | 'right' | 'top' | 'bottom', isError = false) => { + const baseClasses = '!z-[0] !cursor-crosshair !border-none !transition-[colors] !duration-150' + const colorClasses = isError ? '!bg-[var(--text-error)]' : '!bg-[var(--workflow-edge)]' + + const positionClasses = { + left: '!left-[-8px] !h-5 !w-[7px] !rounded-l-[2px] !rounded-r-none hover-hover:!left-[-11px] hover-hover:!w-[10px] hover-hover:!rounded-l-full', + right: + '!right-[-8px] !h-5 !w-[7px] !rounded-r-[2px] !rounded-l-none hover-hover:!right-[-11px] hover-hover:!w-[10px] hover-hover:!rounded-r-full', + top: '!top-[-8px] !h-[7px] !w-5 !rounded-t-[2px] !rounded-b-none hover-hover:!top-[-11px] hover-hover:!h-[10px] hover-hover:!rounded-t-full', + bottom: + '!bottom-[-8px] !h-[7px] !w-5 !rounded-b-[2px] !rounded-t-none hover-hover:!bottom-[-11px] hover-hover:!h-[10px] hover-hover:!rounded-b-full', + } + + return cn(baseClasses, colorClasses, positionClasses[position]) +} + +const getHandleStyle = (position: 'horizontal' | 'vertical') => { + if (position === 'horizontal') { + return { top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`, transform: 'translateY(-50%)' } + } + return { left: '50%', transform: 'translateX(-50%)' } +} + +/** + * Props for the pure workflow-block renderer. + * + * Presentation comes from the editor (or docs) container: visual flags + * (enabled/locked/pending/ring), handle topology (condition/router rows), and + * the resolved badge state (child-deploy, schedule, webhook) are all computed + * upstream and passed in. The block icon, content rows, and editor-only action + * bar are injected as slots so the pure renderer carries no store, query, or + * registry coupling. + */ +export interface WorkflowBlockViewProps { + /** Block identity and visual state, resolved by the container. */ + id: string + type: string + name: string + isPending?: boolean + isEnabled: boolean + isLocked: boolean + hasRing: boolean + ringStyles: string + /** Resolved run-path outcome, drives the muted-name styling. */ + runPathStatus?: BlockRunStatus + /** Block icon component and its background color. */ + Icon: ComponentType<{ className?: string }> + iconBgColor: string + + /** Handle orientation and topology, resolved by the container. */ + horizontalHandles: boolean + shouldShowDefaultHandles: boolean + hasContentBelowHeader: boolean + conditionRows: { id: string; title: string; value: string }[] + routerRows: { id: string; value: string }[] + /** Connection-cycle guard; reads fresh edge state on every call. */ + wouldCreateConnectionCycle: (source: string, target: string) => boolean + + /** Child-workflow deploy badge state. */ + isWorkflowSelector: boolean + childWorkflowId?: string + childIsDeployed: boolean | null + childNeedsRedeploy: boolean + isDeploying: boolean + canAdmin: boolean + onDeployChild: () => void + + /** Schedule badge state. */ + shouldShowScheduleBadge: boolean + scheduleIsDisabled: boolean + onReactivateSchedule: () => void + + /** Webhook badge state. */ + showWebhookIndicator: boolean + webhookProvider?: string + webhookPath?: string + webhookProviderName?: string + isWebhookConfigured: boolean + isWebhookDisabled: boolean + webhookId?: string + onReactivateWebhook: () => void + + /** Selects this block in the editor panel. */ + onSelect: () => void + /** Ref attached to the inner content container. */ + contentRef?: Ref + /** Editor-only action bar; omit in read-only / preview contexts. */ + actionBar?: ReactNode + /** Collapsed subblock summary rows, built by the container. */ + rows: ReactNode +} + +/** + * Pure renderer for a workflow block: a header (icon, name, status badges), an + * optional content section of collapsed subblock rows, and the full handle + * topology (default/condition/router/error connection handles). + */ +export function WorkflowBlockView({ + id, + type, + name, + isPending, + isEnabled, + isLocked, + hasRing, + ringStyles, + runPathStatus, + Icon, + iconBgColor, + horizontalHandles, + shouldShowDefaultHandles, + hasContentBelowHeader, + conditionRows, + routerRows, + wouldCreateConnectionCycle, + isWorkflowSelector, + childWorkflowId, + childIsDeployed, + childNeedsRedeploy, + isDeploying, + canAdmin, + onDeployChild, + shouldShowScheduleBadge, + scheduleIsDisabled, + onReactivateSchedule, + showWebhookIndicator, + webhookProvider, + webhookPath, + webhookProviderName, + isWebhookConfigured, + isWebhookDisabled, + webhookId, + onReactivateWebhook, + onSelect, + contentRef, + actionBar, + rows, +}: WorkflowBlockViewProps) { + return ( +
+
handleKeyboardActivation(event, onSelect)} + className={cn( + 'workflow-drag-handle relative z-[20] w-[250px] cursor-grab select-none rounded-lg border border-[var(--border-1)] bg-[var(--surface-2)] [&:active]:cursor-grabbing' + )} + > + {isPending && ( +
+ Next Step +
+ )} + + {actionBar} + + {shouldShowDefaultHandles && ( + { + if (connection.source === id) return false + return !wouldCreateConnectionCycle(connection.source!, connection.target!) + }} + /> + )} + +
+
+
+ +
+ + {name} + +
+
+ {isWorkflowSelector && + childWorkflowId && + typeof childIsDeployed === 'boolean' && + (!childIsDeployed || childNeedsRedeploy) && ( + + + { + e.stopPropagation() + onDeployChild() + }} + > + {isDeploying ? 'Deploying...' : !childIsDeployed ? 'undeployed' : 'redeploy'} + + + + + {!canAdmin + ? 'Admin permission required to deploy' + : !childIsDeployed + ? 'Click to deploy' + : 'Click to redeploy'} + + + + )} + {!isEnabled && !isLocked && disabled} + {isLocked && locked} + + {type === 'schedule' && shouldShowScheduleBadge && scheduleIsDisabled && ( + + + { + e.stopPropagation() + onReactivateSchedule() + }} + > + disabled + + + + Click to reactivate + + + )} + + {showWebhookIndicator && ( + + + + Webhook + + + + {webhookProvider && webhookPath ? ( + <> +

{webhookProviderName} Webhook

+

Path: {webhookPath}

+ + ) : ( +

+ This workflow is triggered by a webhook. +

+ )} +
+
+ )} + + {isWebhookConfigured && isWebhookDisabled && webhookId && ( + + + { + e.stopPropagation() + onReactivateWebhook() + }} + > + disabled + + + + Click to reactivate + + + )} + {/* {isActive && ( +
+ + )} */} +
+
+ + {hasContentBelowHeader &&
{rows}
} + + {type === 'condition' && ( + <> + {conditionRows.map((cond, condIndex) => { + const topOffset = + HANDLE_POSITIONS.CONDITION_START_Y + + condIndex * HANDLE_POSITIONS.CONDITION_ROW_HEIGHT + return ( + { + if (connection.target === id) return false + return !wouldCreateConnectionCycle(connection.source!, connection.target!) + }} + /> + ) + })} + { + if (connection.target === id) return false + return !wouldCreateConnectionCycle(connection.source!, connection.target!) + }} + /> + + )} + + {type === 'router_v2' && ( + <> + {routerRows.map((route, routeIndex) => { + // +1 row offset for context row at the top + const topOffset = + HANDLE_POSITIONS.CONDITION_START_Y + + (routeIndex + 1) * HANDLE_POSITIONS.CONDITION_ROW_HEIGHT + return ( + { + if (connection.target === id) return false + return !wouldCreateConnectionCycle(connection.source!, connection.target!) + }} + /> + ) + })} + { + if (connection.target === id) return false + return !wouldCreateConnectionCycle(connection.source!, connection.target!) + }} + /> + + )} + + {type !== 'condition' && type !== 'router_v2' && type !== 'response' && ( + <> + { + if (connection.target === id) return false + return !wouldCreateConnectionCycle(connection.source!, connection.target!) + }} + /> + + {shouldShowDefaultHandles && ( + { + if (connection.target === id) return false + return !wouldCreateConnectionCycle(connection.source!, connection.target!) + }} + /> + )} + + )} + {hasRing && ( +
+ )} +
+
+ ) +}