Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/agentflow/src/core/types/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ export interface NodeData extends NodeDefinitionBase {
id: string
inputParams?: InputParam[] // Parameter definitions
inputs?: Record<string, unknown> // Actual values entered by users
disabled?: boolean
disabledBy?: string
// Status properties
status?: ExecutionStatus
error?: string
Expand Down
83 changes: 83 additions & 0 deletions packages/agentflow/src/core/utils/disabledNodes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import type { FlowEdge, FlowNode } from '@/core/types'

export const isNodeExplicitlyDisabled = (node: FlowNode): boolean => {
const disabled = node?.data?.disabled
return disabled === true || String(disabled) === 'true'
}

export const recalculateDisabledNodes = (nodes: FlowNode[], edges: FlowEdge[]): FlowNode[] => {
const explicitlyDisabledNodeIds = new Set<string>(
nodes
.filter((node) => {
const disabled = node.data?.disabled === true || String(node.data?.disabled) === 'true'
const disabledBy = node.data?.disabledBy
return disabled && !disabledBy
})
.map((node) => node.id)
)

const outgoingEdges = new Map<string, string[]>()
for (const edge of edges) {
const targets = outgoingEdges.get(edge.source) || []
targets.push(edge.target)
outgoingEdges.set(edge.source, targets)
}

const disabledByMap = new Map<string, string>()

for (const rootId of explicitlyDisabledNodeIds) {
const queue = [rootId]
const visited = new Set<string>([rootId])

while (queue.length > 0) {
const currentId = queue.shift()!
const targets = outgoingEdges.get(currentId) || []
for (const targetId of targets) {
if (visited.has(targetId)) continue
visited.add(targetId)

if (explicitlyDisabledNodeIds.has(targetId)) continue

if (!disabledByMap.has(targetId)) {
disabledByMap.set(targetId, rootId)
}
queue.push(targetId)
}
}
}

return nodes.map((node) => {
const isExplicit = explicitlyDisabledNodeIds.has(node.id)
if (isExplicit) {
const { disabledBy, ...nextData } = node.data

Check warning on line 52 in packages/agentflow/src/core/utils/disabledNodes.ts

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 20.20.2)

'disabledBy' is assigned a value but never used. Allowed unused vars must match /^_/u
return {
...node,
data: {
...nextData,
disabled: true
}
}
}

const disabledBy = disabledByMap.get(node.id)
if (disabledBy) {
return {
...node,
data: {
...node.data,
disabled: true,
disabledBy
}
}
}

const { disabled, disabledBy: _, ...nextData } = node.data

Check warning on line 74 in packages/agentflow/src/core/utils/disabledNodes.ts

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 20.20.2)

'disabled' is assigned a value but never used. Allowed unused vars must match /^_/u
return {
...node,
data: {
...nextData,
disabled: false
}
}
})
}
Comment thread
udaykumar-dhokia marked this conversation as resolved.
1 change: 1 addition & 0 deletions packages/agentflow/src/core/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@ export { buildDynamicOutputAnchors, parseOutputHandleIndex } from './dynamicOutp
export { getDefinedStateKeys, getUpstreamNodes } from './variableUtils'

// Node version detection and upgrade utilities
export { isNodeExplicitlyDisabled, recalculateDisabledNodes } from './disabledNodes'
export { getNodeVersionWarning, isNodeOutdated, upgradeNodeData } from './nodeVersionUtils'
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,14 @@ jest.mock('../styled', () => ({

const mockDeleteNode = jest.fn()
const mockDuplicateNode = jest.fn()
const mockToggleNodeDisabled = jest.fn()
const mockOpenNodeEditor = jest.fn()

jest.mock('@/infrastructure/store', () => ({
useAgentflowContext: () => ({
deleteNode: mockDeleteNode,
duplicateNode: mockDuplicateNode
duplicateNode: mockDuplicateNode,
toggleNodeDisabled: mockToggleNodeDisabled
}),
useConfigContext: () => ({ isDarkMode: false })
}))
Expand All @@ -39,7 +41,8 @@ jest.mock('@mui/material/styles', () => ({
palette: {
primary: { main: '#1976d2' },
error: { main: '#d32f2f' },
info: { main: '#0288d1' }
info: { main: '#0288d1' },
warning: { main: '#ed6c02' }
}
})
}))
Expand All @@ -57,6 +60,8 @@ jest.mock('@tabler/icons-react', () => ({
IconCopy: () => <svg />,
IconEdit: () => <svg />,
IconInfoCircle: () => <svg />,
IconPlayerPause: () => <svg />,
IconPlayerPlay: () => <svg />,
IconTrash: () => <svg />
}))

Expand Down Expand Up @@ -97,23 +102,31 @@ describe('NodeToolbarActions', () => {
renderToolbar({ nodeName: 'llmAgentflow' })
expect(screen.getByTitle('Duplicate')).toBeInTheDocument()
expect(screen.getByTitle('Edit')).toBeInTheDocument()
expect(screen.getByTitle('Disable')).toBeInTheDocument()
expect(screen.getByTitle('Delete')).toBeInTheDocument()
})

it('hides Duplicate for startAgentflow', () => {
renderToolbar({ nodeName: 'startAgentflow' })
expect(screen.queryByTitle('Duplicate')).not.toBeInTheDocument()
expect(screen.getByTitle('Edit')).toBeInTheDocument()
expect(screen.getByTitle('Disable')).toBeInTheDocument()
expect(screen.getByTitle('Delete')).toBeInTheDocument()
})

it('hides Edit for stickyNoteAgentflow', () => {
renderToolbar({ nodeName: 'stickyNoteAgentflow' })
expect(screen.queryByTitle('Edit')).not.toBeInTheDocument()
expect(screen.queryByTitle('Disable')).not.toBeInTheDocument()
expect(screen.getByTitle('Duplicate')).toBeInTheDocument()
expect(screen.getByTitle('Delete')).toBeInTheDocument()
})

it('renders Enable when node is disabled', () => {
renderToolbar({ disabled: true })
expect(screen.getByTitle('Enable')).toBeInTheDocument()
})

it('renders Info button when onInfoClick is provided', () => {
renderToolbar({ onInfoClick: jest.fn() })
expect(screen.getByTitle('Info')).toBeInTheDocument()
Expand All @@ -140,6 +153,13 @@ describe('NodeToolbarActions', () => {
expect(mockDeleteNode).toHaveBeenCalledWith('node-1')
})

it('calls toggleNodeDisabled when Disable is clicked', async () => {
const user = userEvent.setup()
renderToolbar()
await user.click(screen.getByTitle('Disable'))
expect(mockToggleNodeDisabled).toHaveBeenCalledWith('node-1')
})

it('calls openNodeEditor when Edit is clicked', async () => {
const user = userEvent.setup()
renderToolbar()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Position } from 'reactflow'

import { ButtonGroup, IconButton } from '@mui/material'
import { useTheme } from '@mui/material/styles'
import { IconCopy, IconEdit, IconInfoCircle, IconTrash } from '@tabler/icons-react'
import { IconCopy, IconEdit, IconInfoCircle, IconPlayerPause, IconPlayerPlay, IconTrash } from '@tabler/icons-react'

import { useAgentflowContext, useConfigContext } from '@/infrastructure/store'

Expand All @@ -14,16 +14,21 @@ export interface NodeToolbarActionsProps {
nodeId: string
nodeName: string
isVisible: boolean
disabled?: boolean
disabledBy?: string
onInfoClick?: () => void
}

/**
* Toolbar with action buttons for a node (duplicate, delete, info)
*/
function NodeToolbarActionsComponent({ nodeId, nodeName, isVisible, onInfoClick }: NodeToolbarActionsProps) {
function NodeToolbarActionsComponent({ nodeId, nodeName, isVisible, disabled, disabledBy, onInfoClick }: NodeToolbarActionsProps) {
const theme = useTheme()
const { isDarkMode } = useConfigContext()
const { deleteNode, duplicateNode } = useAgentflowContext()
const { deleteNode, duplicateNode, toggleNodeDisabled, state } = useAgentflowContext()
const nodes = state?.nodes || []
const disabledByNode = disabledBy ? nodes.find((n) => n.id === disabledBy) : null
const disabledByName = disabledByNode ? disabledByNode.data?.label || disabledByNode.data?.name : disabledBy
const { openNodeEditor } = useOpenNodeEditor()

const handleEditClick = () => {
Expand Down Expand Up @@ -62,6 +67,24 @@ function NodeToolbarActionsComponent({ nodeId, nodeName, isVisible, onInfoClick
<IconEdit size={20} />
</IconButton>
)}
{nodeName !== 'stickyNoteAgentflow' && (
<IconButton
size='small'
title={disabledBy ? `Disabled by upstream node: ${disabledByName}` : disabled ? 'Enable' : 'Disable'}
onClick={() => {
if (!disabledBy) {
toggleNodeDisabled(nodeId)
}
}}
disabled={!!disabledBy}
sx={{
color: isDarkMode ? 'white' : 'inherit',
'&:hover': { color: theme.palette.warning.main }
}}
>
{disabled ? <IconPlayerPlay size={20} /> : <IconPlayerPause size={20} />}
</IconButton>
)}
<IconButton
size='small'
title='Delete'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,14 +80,22 @@ function AgentFlowNodeComponent({ data }: AgentFlowNodeProps) {
nodeId={data.id}
nodeName={data.name}
isVisible={data.selected || isHovered}
disabled={data.disabled}
disabledBy={data.disabledBy}
onInfoClick={() => setShowInfoDialog(true)}
/>

<CardWrapper
content={false}
sx={{
borderColor: hasValidationErrors ? tokens.colors.border.validation : stateColor,
borderWidth: hasValidationErrors ? '2px' : '1px',
borderColor: data.disabled
? tokens.colors.semantic.warning
: hasValidationErrors
? tokens.colors.border.validation
: stateColor,
borderWidth: data.disabled || hasValidationErrors ? '2px' : '1px',
borderStyle: data.disabled ? 'dashed' : 'solid',
opacity: data.disabled ? 0.48 : 1,
boxShadow: data.selected ? `0 0 0 1px ${stateColor} !important` : 'none',
minHeight,
height: 'auto',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,14 +111,18 @@ function IterationNodeComponent({ data }: IterationNodeProps) {
nodeId={data.id}
nodeName={data.name}
isVisible={data.selected || isHovered}
disabled={data.disabled}
disabledBy={data.disabledBy}
onInfoClick={() => setShowInfoDialog(true)}
/>
<NodeResizer minWidth={300} minHeight={minHeight} onResizeEnd={onResizeEnd} />
<CardWrapper
content={false}
sx={{
borderColor: stateColor,
borderWidth: '1px',
borderColor: data.disabled ? theme.palette.warning.main : stateColor,
borderWidth: data.disabled ? '2px' : '1px',
borderStyle: data.disabled ? 'dashed' : 'solid',
opacity: data.disabled ? 0.48 : 1,
boxShadow: data.selected ? `0 0 0 1px ${stateColor} !important` : 'none',
minHeight,
minWidth: 300,
Expand Down
48 changes: 41 additions & 7 deletions packages/agentflow/src/infrastructure/store/AgentflowContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
NodeData,
NodeDataSchema
} from '@/core/types'
import { getDefinedStateKeys, getUniqueNodeId, isNodeOutdated, upgradeNodeData } from '@/core/utils'
import { getDefinedStateKeys, getUniqueNodeId, isNodeOutdated, recalculateDisabledNodes, upgradeNodeData } from '@/core/utils'

import { agentflowReducer, initialState, normalizeNodes } from './agentflowReducer'

Expand Down Expand Up @@ -70,6 +70,7 @@
// Node operations
deleteNode: (nodeId: string) => void
duplicateNode: (nodeId: string, distance?: number) => void
toggleNodeDisabled: (nodeId: string) => void
updateNodeData: (nodeId: string, data: Partial<FlowNode['data']>, edges?: FlowEdge[]) => void

// Edge operations
Expand Down Expand Up @@ -198,14 +199,15 @@
}
collectDescendants(nodeId)

const newNodes = state.nodes.filter((node) => !toDelete.has(node.id))
const newEdges = state.edges.filter((edge) => !toDelete.has(edge.source) && !toDelete.has(edge.target))
syncStateUpdate({ nodes: newNodes, edges: newEdges })
const remainingNodes = state.nodes.filter((node) => !toDelete.has(node.id))
const remainingEdges = state.edges.filter((edge) => !toDelete.has(edge.source) && !toDelete.has(edge.target))
const recalculatedNodes = recalculateDisabledNodes(remainingNodes, remainingEdges)
syncStateUpdate({ nodes: recalculatedNodes, edges: remainingEdges })

// Notify parent of flow change so the deletion is persisted
if (onFlowChangeRef.current) {
const viewport = state.reactFlowInstance?.getViewport() || { x: 0, y: 0, zoom: 1 }
onFlowChangeRef.current({ nodes: newNodes, edges: newEdges, viewport })
onFlowChangeRef.current({ nodes: recalculatedNodes, edges: remainingEdges, viewport })
}
},
[state.nodes, state.edges, state.reactFlowInstance, syncStateUpdate]
Expand Down Expand Up @@ -294,16 +296,47 @@
[state.nodes, state.edges, state.reactFlowInstance, syncStateUpdate]
)

const toggleNodeDisabled = useCallback(
(nodeId: string) => {
const targetNode = state.nodes.find((node) => node.id === nodeId)
const shouldDisable = !(targetNode?.data?.disabled === true || String(targetNode?.data?.disabled) === 'true')

const nextNodes = state.nodes.map((node) => {
if (node.id === nodeId) {
const { disabledBy, ...nextData } = node.data

Check warning on line 306 in packages/agentflow/src/infrastructure/store/AgentflowContext.tsx

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 20.20.2)

'disabledBy' is assigned a value but never used. Allowed unused vars must match /^_/u
return {
...node,
data: {
...nextData,
disabled: shouldDisable
}
}
}
return node
})

const recalculatedNodes = recalculateDisabledNodes(nextNodes, state.edges)
syncStateUpdate({ nodes: recalculatedNodes })

if (onFlowChangeRef.current) {
const viewport = state.reactFlowInstance?.getViewport() || { x: 0, y: 0, zoom: 1 }
onFlowChangeRef.current({ nodes: recalculatedNodes, edges: state.edges, viewport })
}
},
[state.nodes, state.edges, state.reactFlowInstance, syncStateUpdate]
)

// Edge operations
const deleteEdge = useCallback(
(edgeId: string) => {
const newEdges = state.edges.filter((edge) => edge.id !== edgeId)
syncStateUpdate({ edges: newEdges })
const recalculatedNodes = recalculateDisabledNodes(state.nodes, newEdges)
syncStateUpdate({ nodes: recalculatedNodes, edges: newEdges })

// Notify parent of flow change so the deletion is persisted
if (onFlowChangeRef.current) {
const viewport = state.reactFlowInstance?.getViewport() || { x: 0, y: 0, zoom: 1 }
onFlowChangeRef.current({ nodes: state.nodes, edges: newEdges, viewport })
onFlowChangeRef.current({ nodes: recalculatedNodes, edges: newEdges, viewport })
}
},
[state.nodes, state.edges, state.reactFlowInstance, syncStateUpdate]
Expand Down Expand Up @@ -415,6 +448,7 @@
setReactFlowInstance,
deleteNode,
duplicateNode,
toggleNodeDisabled,
updateNodeData,
deleteEdge,
openEditDialog,
Expand Down
2 changes: 2 additions & 0 deletions packages/components/src/Interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,8 @@ export interface INodeData extends INodeProperties {
credential?: string
instance?: any
loadMethod?: string // method to load async options
disabled?: boolean
disabledBy?: string
}

export interface INodeCredential {
Expand Down
2 changes: 2 additions & 0 deletions packages/server/src/Interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,8 @@ export interface INodeData extends INodeDataFromComponent {
inputAnchors: INodeParams[]
inputParams: INodeParams[]
outputAnchors: INodeParams[]
disabled?: boolean
disabledBy?: string
}

export interface IReactFlowNode {
Expand Down
Loading
Loading