Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { Node } from '@xyflow/react'
import { ComponentPropsWithRef, forwardRef } from 'react'
import { v4 as uuidv4 } from 'uuid'
Expand Down Expand Up @@ -163,6 +163,29 @@

const submitAddVariable = ({ variableName }: { variableName: string }) => {
if (!variableName.trim()) {
// For variable nodes on block handles, clearing the name resets the variable
// so that a branch (contacts/coils) can be placed on the handle instead.
if (blockType === 'variable') {
const { rung, node: variableNode } = getLadderPouVariablesRungNodeAndEdges(editor, pous, ladderFlows, {
nodeId: (block as Node<BasicNodeData>).id,
})
if (rung && variableNode) {
updateNode({
editorName: editor.meta.name,
rungId: rung.id,
nodeId: variableNode.id,
node: {
...variableNode,
data: {
...variableNode.data,
variable: { id: '', name: '' },
},
},
})
}
return
}
Comment on lines 165 to +187
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clear path leaves stale block-pin linkage metadata.

On Line 173-184, clearing a variable updates only the variable node. The connected block’s connectedVariables entry for that handle is not cleared, so handle state can remain logically “occupied” after clear.

💡 Suggested fix
         if (blockType === 'variable') {
           const { rung, node: variableNode } = getLadderPouVariablesRungNodeAndEdges(editor, pous, ladderFlows, {
             nodeId: (block as Node<BasicNodeData>).id,
           })
           if (rung && variableNode) {
             updateNode({
               editorName: editor.meta.name,
               rungId: rung.id,
               nodeId: variableNode.id,
               node: {
                 ...variableNode,
                 data: {
                   ...variableNode.data,
                   variable: { id: '', name: '' },
                 },
               },
             })
+
+            const relatedBlock = rung.nodes.find((n) => n.id === (variableNode as VariableNode).data.block?.id)
+            if (relatedBlock) {
+              const existing = Array.isArray((relatedBlock.data as BlockNodeData<BlockVariant>).connectedVariables)
+                ? (relatedBlock.data as BlockNodeData<BlockVariant>).connectedVariables
+                : []
+              const nextConnected = existing.filter(
+                (v) =>
+                  v.type !== (variableNode as VariableNode).data.variant ||
+                  v.handleId !== (variableNode as VariableNode).data.block.handleId,
+              )
+              updateNode({
+                editorName: editor.meta.name,
+                rungId: rung.id,
+                nodeId: relatedBlock.id,
+                node: {
+                  ...relatedBlock,
+                  data: {
+                    ...relatedBlock.data,
+                    connectedVariables: nextConnected,
+                  },
+                },
+              })
+            }
           }
           return
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/frontend/components/_atoms/graphical-editor/ladder/autocomplete/index.tsx`
around lines 165 - 187, Summary: Clearing a variable node leaves the parent
block's connectedVariables metadata pointing at a now-empty handle, so the
handle remains logically occupied. Fix: when handling the blockType ===
'variable' clear path, after you update the variable node via updateNode (the
existing call that updates variableNode.data.variable), also locate the parent
block node (the local block Node<BasicNodeData> in this scope) and update its
data.connectedVariables to remove/clear the entry for the handle (use the same
updateNode API to persist the change). Use getLadderPouVariablesRungNodeAndEdges
to get rung/variableNode as before and then call updateNode for the block node
to set block.data.connectedVariables[handleId] = undefined or delete that key so
the handle is no longer considered occupied.


toast({
title: 'Invalid variable name',
description: 'Variable name cannot be empty',
Expand Down
85 changes: 65 additions & 20 deletions src/frontend/components/_atoms/graphical-editor/ladder/block.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { FocusEvent, useEffect, useMemo, useRef, useState } from 'react'
import { v4 as uuidv4 } from 'uuid'

Expand All @@ -10,6 +10,7 @@
import { cn } from '../../../../utils/cn'
import { toast } from '../../../_features/[app]/toast/use-toast'
import { updateDiagramElementsPosition } from '../../../_molecules/graphical-editor/ladder/rung/ladder-utils/elements/diagram'
import { reconcileBranchesIfNeeded } from '../../../_molecules/graphical-editor/ladder/rung/ladder-utils/elements/handle-branch'
import { HighlightedTextArea } from '../../highlighted-textarea'
import { InputWithRef } from '../../input'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../tooltip'
Expand Down Expand Up @@ -60,7 +61,7 @@
editorActions: { updateModelVariables },
libraries,
ladderFlows,
ladderFlowActions: { setNodes, setEdges },
ladderFlowActions: { setNodes, setEdges, setHandleBranches },
project: {
data: { pous },
},
Expand Down Expand Up @@ -265,6 +266,19 @@

newNodes = newNodes.map((n) => (n.id === node.id ? newBlockNode : n))

// Reconcile branches before main edge remapping so branch edges get new IDs
const reconciled = reconcileBranchesIfNeeded(
{ ...rung, nodes: newNodes, edges: newEdges },
node.id,
newBlockNode.id,
(libraryBlock as { variables?: BlockVariant['variables'] })?.variables ?? [],
)
if (reconciled) {
newNodes = reconciled.nodes
newEdges = reconciled.edges
}
const reconciledHandleBranches = reconciled?.handleBranches

edges.source?.forEach((edge) => {
const newEdge = {
...edge,
Expand All @@ -289,6 +303,7 @@
...rung,
nodes: newNodes,
edges: newEdges,
...(reconciledHandleBranches && { handleBranches: reconciledHandleBranches }),
},
[rung.defaultBounds[0], rung.defaultBounds[1]],
)
Expand All @@ -310,6 +325,13 @@
rungId: rung.id,
edges: variableEdges,
})
if (reconciledHandleBranches) {
setHandleBranches({
editorName: editor.meta.name,
rungId: rung.id,
handleBranches: reconciledHandleBranches,
})
}

setWrongName(false)
}
Expand Down Expand Up @@ -349,24 +371,26 @@
onKeyDown={(e) => e.key === 'Enter' && inputNameRef.current?.blur()}
ref={inputNameRef}
/>
{inputConnectors.map((connector, index) => (
<div
key={index}
className='absolute text-xs'
style={{ top: DEFAULT_BLOCK_CONNECTOR_Y + index * DEFAULT_BLOCK_CONNECTOR_Y_OFFSET - 10, left: 6 }}
>
{connector}
</div>
))}
{outputConnectors.map((connector, index) => (
<div
key={index}
className='absolute text-xs'
style={{ top: DEFAULT_BLOCK_CONNECTOR_Y + index * DEFAULT_BLOCK_CONNECTOR_Y_OFFSET - 10, right: 6 }}
>
{connector}
</div>
))}
{inputConnectors.map((connector, index) => {
const handle = (data as BasicNodeData).inputHandles?.[index]
const top =
(handle?.relPosition?.y ?? DEFAULT_BLOCK_CONNECTOR_Y + index * DEFAULT_BLOCK_CONNECTOR_Y_OFFSET) - 10
return (
<div key={index} className='absolute text-xs' style={{ top, left: 6 }}>
{connector}
</div>
)
})}
{outputConnectors.map((connector, index) => {
const handle = (data as BasicNodeData).outputHandles?.[index]
const top =
(handle?.relPosition?.y ?? DEFAULT_BLOCK_CONNECTOR_Y + index * DEFAULT_BLOCK_CONNECTOR_Y_OFFSET) - 10
return (
<div key={index} className='absolute text-xs' style={{ top, right: 6 }}>
{connector}
</div>
)
})}
</div>
)
}
Expand All @@ -383,7 +407,7 @@
snapshotActions: { pushToHistory },
libraries: { user: userLibraries },
ladderFlows,
ladderFlowActions: { updateNode, setNodes, setEdges },
ladderFlowActions: { updateNode, setNodes, setEdges, setHandleBranches: setHandleBranchesBlock },
} = useOpenPLCStore()
const { type: blockType } = (data.variant as BlockVariant) ?? DEFAULT_BLOCK_TYPE
const documentation = getBlockDocumentation(data.variant as newBlockVariant)
Expand Down Expand Up @@ -722,6 +746,19 @@

newNodes = newNodes.map((n) => (n.id === node.id ? newBlockNode : n))

// Reconcile branches before main edge remapping so branch edges get new IDs
const reconciled2 = reconcileBranchesIfNeeded(
{ ...rung, nodes: newNodes, edges: newEdges },
node.id,
newBlockNode.id,
newNodeVariables as BlockVariant['variables'],
)
if (reconciled2) {
newNodes = reconciled2.nodes
newEdges = reconciled2.edges
}
const reconciledHandleBranches = reconciled2?.handleBranches

edges.source?.forEach((edge) => {
const newEdge = {
...edge,
Expand All @@ -746,6 +783,7 @@
...rung,
nodes: newNodes,
edges: newEdges,
...(reconciledHandleBranches && { handleBranches: reconciledHandleBranches }),
},
[rung.defaultBounds[0], rung.defaultBounds[1]],
)
Expand All @@ -760,6 +798,13 @@
rungId: rung.id,
edges: variableEdges,
})
if (reconciledHandleBranches) {
setHandleBranchesBlock({
editorName: editor.meta.name,
rungId: rung.id,
handleBranches: reconciledHandleBranches,
})
}
}

return (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,41 @@
import { useUpdateNodeInternals } from '@xyflow/react'
import { useEffect, useMemo } from 'react'

import { CustomHandle } from './handle'
import { DEFAULT_POWER_RAIL_HEIGHT, DEFAULT_POWER_RAIL_WIDTH } from './utils/constants'
import { PowerRailProps } from './utils/types'

export const PowerRail = ({ data }: PowerRailProps) => {
export const PowerRail = ({ id, data }: PowerRailProps) => {
const updateNodeInternals = useUpdateNodeInternals()

// Calculate dynamic height to cover all handles (including branch handles)
const railHeight = useMemo(() => {
if (data.handles.length <= 1) return DEFAULT_POWER_RAIL_HEIGHT

let maxRelY = 0
for (const handle of data.handles) {
const relY = handle.relPosition?.y ?? 0
if (relY > maxRelY) maxRelY = relY
}

// Add padding below the lowest handle
const dynamicHeight = maxRelY + DEFAULT_POWER_RAIL_HEIGHT / 2
return Math.max(DEFAULT_POWER_RAIL_HEIGHT, dynamicHeight)
}, [data.handles])

// Derive a stable key from handle IDs so we re-scan when handles are added,
// removed, or replaced (e.g. reconcileBranches swaps the handle ID).
const handleIds = useMemo(() => data.handles.map((h) => h.id).join(','), [data.handles])

// Force ReactFlow to re-scan handle bounds when handles change count, IDs, or position
useEffect(() => {
updateNodeInternals(id)
}, [handleIds, railHeight, id, updateNodeInternals])

return (
<>
<svg width={DEFAULT_POWER_RAIL_WIDTH} height={DEFAULT_POWER_RAIL_HEIGHT} xmlns='http://www.w3.org/2000/svg'>
<rect width={DEFAULT_POWER_RAIL_WIDTH} height={DEFAULT_POWER_RAIL_HEIGHT} className='fill-neutral-500' />
<svg width={DEFAULT_POWER_RAIL_WIDTH} height={railHeight} xmlns='http://www.w3.org/2000/svg'>
<rect width={DEFAULT_POWER_RAIL_WIDTH} height={railHeight} className='fill-neutral-500' />
</svg>
{data.handles.map((handle, index) => (
<CustomHandle key={index} {...handle} />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import type { Node, NodeProps } from '@xyflow/react'
import { ReactNode } from 'react'

import { PLCVariable } from '../../../../../../middleware/shared/ports'
import type { HandleBranch } from '../../../../../../middleware/shared/ports/types'
import { CustomHandleProps } from '../handle'

// HandleBranch is defined in the ports layer (where RungLadderState lives) so
// the rung type can reference it without violating layer rules. Re-export it
// here so component code can import it from a single nearby location.
export type { HandleBranch }

export type BuilderBasicProps = {
id: string
posX: number
Expand All @@ -24,6 +30,21 @@
draggable: boolean
selectable: boolean
deletable: boolean
/** Marks this node as part of a handle branch (contact/coil on a block input/output) */
branchContext?: {
blockId: string
handleId: string
direction: 'input' | 'output'
}
/** Set on placeholders to indicate dropping here creates a handle branch */
handleBranchTarget?: {
blockId: string
handleId: string
direction: 'input' | 'output'
handlePosition: { x: number; y: number }
/** When set, insert into existing branch at this position in nodeIds. When undefined, create new branch. */
insertIndex?: number
}
}

// block
Expand Down
58 changes: 43 additions & 15 deletions src/frontend/components/_atoms/graphical-editor/ladder/variable.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import * as Popover from '@radix-ui/react-popover'
import { useEffect, useRef, useState } from 'react'

Expand Down Expand Up @@ -128,29 +128,36 @@
* Update inputError state when the table of variables is updated
*/
useEffect(() => {
const {
node: variableNode,
rung,
variables,
} = getLadderPouVariablesRungNodeAndEdges(editor, pous, ladderFlows, {
const { node: variableNode, rung } = getLadderPouVariablesRungNodeAndEdges(editor, pous, ladderFlows, {
nodeId: id,
variableName: data.variable?.name,
})
if (!rung || !variableNode) return

// Use the selected variable from getLadderPouVariablesRungNodeAndEdges which properly
// handles derived types for 'variable' node types (block pin variables)
const variable = variables.selected
// Use the node's current variable name (from the store) as the source of truth.
// The prop `data.variable?.name` may be stale during React's render cycle, so
// looking up the POU variable by the node's actual name avoids overwriting
// user-initiated changes (selection, clearing) with stale prop values.
const nodeVariableName = (variableNode as VariableNode).data.variable.name
const nodeVarRef = (variableNode as VariableNode).data.variable

if (!nodeVariableName) {
setIsAVariable(false)
return
}

// Find the POU variable that matches the node's current variable name
const pouVariables = pous.find((p) => p.name === editor.meta.name)?.interface?.variables ?? []
const variable = pouVariables.find((v) => v.name.toLowerCase() === nodeVariableName.toLowerCase())

if (!variable || !inputVariableRef) {
setIsAVariable(false)
} else {
const nodeVariableName = (variableNode as VariableNode).data.variable.name

const namesMatchCI = variable.name.toLowerCase() === nodeVariableName.toLowerCase()
const caseDiffers = variable.name !== nodeVariableName
const refStale = nodeVarRef !== variable

if (!namesMatchCI || caseDiffers) {
if (!namesMatchCI || caseDiffers || refStale) {
updateNode({
editorName: editor.meta.name,
rungId: rung.id,
Expand Down Expand Up @@ -180,8 +187,6 @@
setIsAVariable(true)
}

if (!rung) return

const relatedBlock = rung.nodes.find((node) => node.id === data.block.id)
if (!relatedBlock) {
setInputError(true)
Expand All @@ -192,15 +197,38 @@
/**
* Handle with the variable input onBlur event
*/
const handleSubmitVariableValueOnTextareaBlur = (variableName?: string) => {
const variableNameToSubmit = variableName || variableValue
const handleSubmitVariableValueOnTextareaBlur = (currentValue?: string) => {
const variableNameToSubmit = currentValue ?? variableValue

const { pou, rung, node } = getLadderPouVariablesRungNodeAndEdges(editor, pous, ladderFlows, {
nodeId: id,
})
if (!pou || !rung || !node) return
const variableNode = node as VariableNode

// Allow clearing a variable from a block handle by submitting an empty name.
// This resets the variable node so a branch (contacts/coils) can be placed instead.
if (!variableNameToSubmit.trim()) {
const emptyVariable = { id: '', name: '' }
setVariableValue('')
setIsAVariable(false)
setInputError(false)
updateNode({
editorName: editor.meta.name,
rungId: rung.id,
nodeId: variableNode.id,
node: {
...variableNode,
data: {
...variableNode.data,
variable: emptyVariable,
},
},
})
updateRelatedNode(rung, variableNode, emptyVariable as PLCVariable)
return
}

// For variable nodes (block pins), allow all types including derived (user-defined types)
// Don't use getVariableByName here as it filters out derived types
let variable: PLCVariable | { name: string } | undefined = (pou.interface?.variables ?? []).find(
Expand Down
4 changes: 2 additions & 2 deletions src/frontend/components/_atoms/highlighted-textarea/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import type { ChangeEvent, ComponentPropsWithRef, UIEvent } from 'react'
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'

Expand All @@ -8,7 +8,7 @@
type HighlightedTextAreaProps = ComponentPropsWithRef<'textarea'> & {
textAreaValue: string
setTextAreaValue: (value: string) => void
handleSubmit?: () => void
handleSubmit?: (currentValue?: string) => void
submitWith?: {
enter: boolean
}
Expand Down Expand Up @@ -147,7 +147,7 @@
highlightDivRef.current.scrollTop = 0
}
props.onBlur?.(e)
if (canSubmit && handleSubmit) handleSubmit()
if (canSubmit && handleSubmit) handleSubmit(e.target.value)
setCanSubmit(true)
}}
onScroll={(e) => onScrollHandler(e)}
Expand Down
Loading
Loading