diff --git a/apps/realtime/src/database/operations.ts b/apps/realtime/src/database/operations.ts index 697ba4099c0..0086975edad 100644 --- a/apps/realtime/src/database/operations.ts +++ b/apps/realtime/src/database/operations.ts @@ -734,6 +734,33 @@ async function handleBlockOperationTx( break } + case BLOCK_OPERATIONS.UPDATE_ERROR_ENABLED: { + if (!payload.id || payload.errorEnabled === undefined) { + throw new Error('Missing required fields for update error enabled operation') + } + + const updateResult = await tx + .update(workflowBlocks) + .set({ + data: sql`jsonb_set( + coalesce(${workflowBlocks.data}, '{}'::jsonb), + '{errorEnabled}', + ${JSON.stringify(payload.errorEnabled)}::jsonb, + true + )`, + updatedAt: new Date(), + }) + .where(and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId))) + .returning({ id: workflowBlocks.id }) + + if (updateResult.length === 0) { + throw new Error(`Block ${payload.id} not found in workflow ${workflowId}`) + } + + logger.debug(`Updated block error output: ${payload.id} -> ${payload.errorEnabled}`) + break + } + case BLOCK_OPERATIONS.UPDATE_CANONICAL_MODE: { if (!payload.id || !payload.canonicalId || !payload.canonicalMode) { throw new Error('Missing required fields for update canonical mode operation') diff --git a/apps/realtime/src/middleware/permissions.ts b/apps/realtime/src/middleware/permissions.ts index 4f4a4296c16..88679cb5622 100644 --- a/apps/realtime/src/middleware/permissions.ts +++ b/apps/realtime/src/middleware/permissions.ts @@ -27,6 +27,7 @@ const WRITE_OPERATIONS: string[] = [ BLOCK_OPERATIONS.TOGGLE_ENABLED, BLOCK_OPERATIONS.UPDATE_PARENT, BLOCK_OPERATIONS.UPDATE_ADVANCED_MODE, + BLOCK_OPERATIONS.UPDATE_ERROR_ENABLED, BLOCK_OPERATIONS.UPDATE_CANONICAL_MODE, BLOCK_OPERATIONS.REPLACE_CANONICAL_MODES, BLOCK_OPERATIONS.TOGGLE_HANDLES, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx index 08bde247dbc..97307ce5c4b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx @@ -1,6 +1,6 @@ import { memo, useCallback } from 'react' import { Button, cn, Duplicate, PlayOutline, Tooltip, Trash2, toast } from '@sim/emcn' -import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, Lock, LogOut, Unlock } from 'lucide-react' +import { Circle, CircleOff, Lock, LogOut, Unlock } from 'lucide-react' import { useShallow } from 'zustand/react/shallow' import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' @@ -14,14 +14,16 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store' const DEFAULT_DUPLICATE_OFFSET = { x: 50, y: 50 } const ACTION_BUTTON_STYLES = [ - 'size-[23px] rounded-lg p-0', - 'border border-[var(--border)] bg-[var(--surface-5)]', - 'text-[var(--text-secondary)]', - 'hover-hover:border-transparent hover-hover:bg-[var(--brand-secondary)] hover-hover:!text-[var(--text-inverse)]', - 'dark:border-transparent dark:bg-[var(--surface-7)] dark:hover-hover:bg-[var(--brand-secondary)]', + 'size-[24px] rounded-md p-0', + 'border-none bg-transparent text-[var(--text-icon)]', + 'hover-hover:bg-[var(--surface-5)] hover-hover:!text-[var(--text-primary)]', + 'dark:hover-hover:bg-[var(--surface-4)]', + 'transition-[background-color,color,opacity,transform] duration-150 active:scale-[0.96]', ].join(' ') -const ICON_SIZE = 'size-[11px]' +const ICON_SIZE = 'size-[14px]' + +type ActionId = 'run' | 'enabled' | 'lock' | 'duplicate' | 'remove' | 'delete' /** * Props for the ActionBar component @@ -33,6 +35,8 @@ interface ActionBarProps { blockType: string /** Whether the action bar is disabled */ disabled?: boolean + /** Places the actions inside the workflow card's border swell. */ + variant?: 'floating' | 'swell' } /** @@ -42,12 +46,16 @@ interface ActionBarProps { * @component */ export const ActionBar = memo( - function ActionBar({ blockId, blockType, disabled = false }: ActionBarProps) { + function ActionBar({ + blockId, + blockType, + disabled = false, + variant = 'floating', + }: ActionBarProps) { const { collaborativeBatchAddBlocks, collaborativeBatchRemoveBlocks, collaborativeBatchToggleBlockEnabled, - collaborativeBatchToggleBlockHandles, collaborativeBatchToggleLocked, } = useCollaborativeWorkflow() const { setPendingSelection } = useWorkflowRegistry() @@ -78,30 +86,22 @@ export const ActionBar = memo( ) }, [blockId, collaborativeBatchAddBlocks, setPendingSelection]) - const { - isEnabled, - horizontalHandles, - parentId, - parentType, - isLocked, - isParentLocked, - isParentDisabled, - } = useWorkflowStore( - useShallow((state) => { - const block = state.blocks[blockId] - const parentId = block?.data?.parentId - const parentBlock = parentId ? state.blocks[parentId] : undefined - return { - isEnabled: block?.enabled ?? true, - horizontalHandles: block?.horizontalHandles ?? false, - parentId, - parentType: parentBlock?.type, - isLocked: block?.locked ?? false, - isParentLocked: parentBlock?.locked ?? false, - isParentDisabled: parentBlock ? !parentBlock.enabled : false, - } - }) - ) + const { isEnabled, parentId, parentType, isLocked, isParentLocked, isParentDisabled } = + useWorkflowStore( + useShallow((state) => { + const block = state.blocks[blockId] + const parentId = block?.data?.parentId + const parentBlock = parentId ? state.blocks[parentId] : undefined + return { + isEnabled: block?.enabled ?? true, + parentId, + parentType: parentBlock?.type, + isLocked: block?.locked ?? false, + isParentLocked: parentBlock?.locked ?? false, + isParentDisabled: parentBlock ? !parentBlock.enabled : false, + } + }) + ) const { activeWorkflowId } = useWorkflowRegistry() const isExecuting = useIsCurrentWorkflowExecuting() @@ -112,7 +112,6 @@ export const ActionBar = memo( const isStartBlock = isInputDefinitionTrigger(blockType) const isResponseBlock = blockType === 'response' const isNoteBlock = blockType === 'note' - const isSubflowBlock = blockType === 'loop' || blockType === 'parallel' const isInsideSubflow = parentId && (parentType === 'loop' || parentType === 'parallel') const snapshot = activeWorkflowId ? getLastExecutionSnapshot(activeWorkflowId) : null @@ -132,6 +131,61 @@ export const ActionBar = memo( isTriggerBlock || (snapshot && incomingEdges.every((edge) => isSourceSatisfied(edge.source))) const canRunFromBlock = dependenciesSatisfied && !isNoteBlock && !isInsideSubflow && !isExecuting + const isSwell = variant === 'swell' + const firstActionId: ActionId = + !isNoteBlock && !isInsideSubflow + ? 'run' + : !isNoteBlock + ? 'enabled' + : userPermissions.canAdmin + ? 'lock' + : !isStartBlock && !isResponseBlock + ? 'duplicate' + : 'delete' + /* + * Icon treatment follows the swell's own fill, published by the card view + * as `data-node-selected`. Keying off React Flow's raw `selected` would + * diverge: an executing block keeps the success ring, so the swell stays + * gray (or retracts) while `selected` is still true. + */ + const actionButtonStyles = cn( + ACTION_BUTTON_STYLES, + isSwell && [ + 'group-data-[node-selected]:text-[var(--surface-2)]', + 'hover-hover:group-data-[node-selected]:bg-[var(--surface-2)]', + 'hover-hover:group-data-[node-selected]:!text-[var(--text-primary)]', + ] + ) + /* + * End-button silhouettes: a straight diagonal edge running parallel to + * the gray swell's taper above it (slope 20/24 ≈ 40° from vertical — the + * falloff curve's central gradient), blended into the top edge with a + * generous r8 arc and meeting the bottom edge with a pointier r2.5 arc; + * the outer corners keep a modest r4. The right (delete) shape is the + * exact mirror of the left (first action) shape. Width is 40px — outer + * diagonal pushed out so the glyph has room before the cut; inner side + * stays tight against neighboring actions. The row is right-[24px] to + * match the swell anchor inset (right-aligned on the card). Glyphs shift + * away from the outer cut (+6 / -6). Play gets an extra +2px because the + * triangle’s optical center sits left of its viewBox center. + */ + const getActionButtonStyles = (actionId: ActionId) => + cn( + actionButtonStyles, + isSwell && + actionId === firstActionId && + "!w-[40px] [clip-path:path('M23.75_0A8_8_0_0_0_17.6_2.88L3.41_19.9A2.5_2.5_0_0_0_5.34_24L36_24A4_4_0_0_0_40_20L40_4A4_4_0_0_0_36_0Z')] [&_svg]:translate-y-px", + isSwell && + actionId === firstActionId && + (actionId === 'run' ? '[&_svg]:translate-x-[8px]' : '[&_svg]:translate-x-[6px]'), + isSwell && + actionId === 'delete' && + "!w-[40px] [clip-path:path('M16.25_0A8_8_0_0_1_22.4_2.88L36.59_19.9A2.5_2.5_0_0_1_34.66_24L4_24A4_4_0_0_1_0_20L0_4A4_4_0_0_1_4_0Z')] [&_svg]:-translate-x-[6px] [&_svg]:translate-y-px", + /* `!` is required: these buttons are also `disabled` when locked, and + the emcn Button base carries `disabled:opacity-70`, which outranks a + plain `opacity-35` on specificity. */ + actionId !== 'lock' && isLocked && '!opacity-35' + ) const handleRunFromBlockClick = useCallback(() => { if (!activeWorkflowId || !canRunFromBlock) return @@ -153,205 +207,210 @@ export const ActionBar = memo( return (
- {!isNoteBlock && !isInsideSubflow && ( - - - - - - - - {(() => { - if (disabled) return getTooltipMessage('Run from block') - if (isExecuting) return 'Running...' - if (!dependenciesSatisfied) return 'Run previous blocks first' - return 'Run from block' - })()} - - - )} +
+
+ {!isNoteBlock && !isInsideSubflow && ( + + + + + + + + {(() => { + if (isLocked || isParentLocked) return 'Block is locked' + if (disabled) return getTooltipMessage('Run from block') + if (isExecuting) return 'Running...' + if (!dependenciesSatisfied) return 'Run previous blocks first' + return 'Run from block' + })()} + + + )} - {!isNoteBlock && ( - - - - - - {isLocked || isParentLocked - ? 'Block is locked' - : !isEnabled && isParentDisabled - ? 'Parent container is disabled' - : getTooltipMessage(isEnabled ? 'Disable Block' : 'Enable Block')} - - - )} + {!isNoteBlock && ( + + + + + + + + {isLocked || isParentLocked + ? 'Block is locked' + : !isEnabled && isParentDisabled + ? 'Parent container is disabled' + : getTooltipMessage(isEnabled ? 'Disable Block' : 'Enable Block')} + + + )} - {userPermissions.canAdmin && ( - - - - - - {isLocked && isParentLocked - ? 'Parent container is locked' - : isLocked - ? 'Unlock Block' - : 'Lock Block'} - - - )} + {userPermissions.canAdmin && ( + + + + + + + + {isLocked && isParentLocked + ? 'Parent container is locked' + : isLocked + ? 'Unlock Block' + : 'Lock Block'} + + + )} - {!isStartBlock && !isResponseBlock && ( - - - - - - {isLocked || isParentLocked - ? 'Block is locked' - : getTooltipMessage('Duplicate Block')} - - - )} + {!isStartBlock && !isResponseBlock && ( + + + + + + + + {isLocked || isParentLocked + ? 'Block is locked' + : getTooltipMessage('Duplicate Block')} + + + )} - {!isNoteBlock && !isSubflowBlock && ( - - - - - - {isLocked || isParentLocked - ? 'Block is locked' - : getTooltipMessage(horizontalHandles ? 'Vertical Ports' : 'Horizontal Ports')} - - - )} - - {!isStartBlock && parentId && (parentType === 'loop' || parentType === 'parallel') && ( - - - - - - {isLocked || isParentLocked - ? 'Block is locked' - : getTooltipMessage('Remove from Subflow')} - - - )} + {!isStartBlock && parentId && (parentType === 'loop' || parentType === 'parallel') && ( + + + + + + + + {isLocked || isParentLocked + ? 'Block is locked' + : getTooltipMessage('Remove from Subflow')} + + + )} - - - - - - {isLocked || isParentLocked ? 'Block is locked' : getTooltipMessage('Delete Block')} - - + + + + + + + + {isLocked || isParentLocked ? 'Block is locked' : getTooltipMessage('Delete Block')} + + +
+
) }, @@ -367,7 +426,8 @@ export const ActionBar = memo( return ( prevProps.blockId === nextProps.blockId && prevProps.blockType === nextProps.blockType && - prevProps.disabled === nextProps.disabled + prevProps.disabled === nextProps.disabled && + prevProps.variant === nextProps.variant ) } ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/block-menu/block-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/block-menu/block-menu.tsx index acc8e422da7..c92301ffe9b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/block-menu/block-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/block-menu/block-menu.tsx @@ -11,7 +11,6 @@ export interface BlockInfo { id: string type: string enabled: boolean - horizontalHandles: boolean parentId?: string parentType?: string locked?: boolean @@ -34,7 +33,6 @@ export interface BlockMenuProps { onDuplicate: () => void onDelete: () => void onToggleEnabled: () => void - onToggleHandles: () => void onRemoveFromSubflow: () => void onOpenEditor: () => void onRename: () => void @@ -74,7 +72,6 @@ export function BlockMenu({ onDuplicate, onDelete, onToggleEnabled, - onToggleHandles, onRemoveFromSubflow, onOpenEditor, onRename, @@ -207,17 +204,6 @@ export function BlockMenu({ {hasBlockWithDisabledParent ? 'Parent is disabled' : getToggleEnabledLabel()} )} - {!allNoteBlocks && !isSubflow && ( - { - onToggleHandles() - onClose() - }} - > - Flip Handles - - )} {canRemoveFromSubflow && (
{(blockConfig || isSubflow) && currentBlock?.type !== 'note' && ( -
-
+ )} {isRenaming ? (