-
Notifications
You must be signed in to change notification settings - Fork 91
fix(graphical-editor): render VAR_IN_OUT as a single input-side pin, like CODESYS #1012
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: development
Are you sure you want to change the base?
Changes from all commits
97a5d57
9a84fea
1f1fa12
10674e8
901f262
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,190 @@ | ||
| import { | ||
| blockInputVariables, | ||
| blockOutputVariables, | ||
| IN_OUT_MARKER_WIDTH, | ||
| findOccupiedInOutPin, | ||
| inOutVariableNames, | ||
| migrateInOutSourceEdges, | ||
| stripInOutOutputHandles, | ||
| } from '../in-out-pin-rules' | ||
|
|
||
| /** Irrigation_Main_Controller: State is VAR_IN_OUT, Moisture and T_Max are inputs. */ | ||
| const variables = [ | ||
| { name: 'State', class: 'inOut', type: { definition: 'user-data-type', value: 'Irrigation_State' } }, | ||
| { name: 'Moisture', class: 'input', type: { definition: 'base-type', value: 'BOOL' } }, | ||
| { name: 'T_Max', class: 'input', type: { definition: 'base-type', value: 'TIME' } }, | ||
| ] | ||
|
|
||
| const handle = (id: string, type: 'source' | 'target', top: number) => ({ | ||
| id, | ||
| type, | ||
| glbPosition: { x: 0, y: top }, | ||
| relPosition: { x: 0, y: top }, | ||
| style: { top }, | ||
| }) | ||
|
|
||
| const blockNode = (id = 'imc') => ({ | ||
| id, | ||
| type: 'block', | ||
| position: { x: 0, y: 0 }, | ||
| data: { | ||
| variant: { name: 'Irrigation_Main_Controller', variables }, | ||
| inputHandles: [handle('State', 'target', 48), handle('Moisture', 'target', 96), handle('T_Max', 'target', 144)], | ||
| // What a project saved before the change carries: an output pin for the in-out. | ||
| outputHandles: [handle('State', 'source', 48)], | ||
| handles: [ | ||
| handle('State', 'target', 48), | ||
| handle('Moisture', 'target', 96), | ||
| handle('T_Max', 'target', 144), | ||
| handle('State', 'source', 48), | ||
| ], | ||
| outputConnector: handle('State', 'source', 48), | ||
| }, | ||
| }) | ||
|
|
||
| describe('VAR_IN_OUT is a single input-side pin', () => { | ||
| it('puts an in-out parameter on the input side only', () => { | ||
| expect(blockInputVariables(variables).map((v) => v.name)).toEqual(['State', 'Moisture', 'T_Max']) | ||
| expect(blockOutputVariables(variables).map((v) => v.name)).toEqual([]) | ||
| expect([...inOutVariableNames(variables)]).toEqual(['State']) | ||
| }) | ||
|
|
||
| it('leaves plain inputs and outputs alone', () => { | ||
| const ton = [ | ||
| { name: 'IN', class: 'input', type: { definition: 'base-type', value: 'BOOL' } }, | ||
| { name: 'PT', class: 'input', type: { definition: 'base-type', value: 'TIME' } }, | ||
| { name: 'Q', class: 'output', type: { definition: 'base-type', value: 'BOOL' } }, | ||
| { name: 'ET', class: 'output', type: { definition: 'base-type', value: 'TIME' } }, | ||
| ] | ||
| expect(blockInputVariables(ton).map((v) => v.name)).toEqual(['IN', 'PT']) | ||
| expect(blockOutputVariables(ton).map((v) => v.name)).toEqual(['Q', 'ET']) | ||
| expect(inOutVariableNames(ton).size).toBe(0) | ||
| }) | ||
| }) | ||
|
|
||
| describe('an in-out pin accepts exactly one variable', () => { | ||
| const graph = { | ||
| nodes: [blockNode()], | ||
| edges: [{ source: 'v1', sourceHandle: 'output-variable', target: 'imc', targetHandle: 'State' }], | ||
| } | ||
|
|
||
| it('rejects a second connection to an in-out pin', () => { | ||
| expect(findOccupiedInOutPin({ target: 'imc', targetHandle: 'State' }, graph)).toBe('State') | ||
| }) | ||
|
|
||
| it('allows the first connection to an in-out pin', () => { | ||
| expect(findOccupiedInOutPin({ target: 'imc', targetHandle: 'State' }, { ...graph, edges: [] })).toBeUndefined() | ||
| }) | ||
|
|
||
| it('does not restrict ordinary input pins', () => { | ||
| const busy = { | ||
| ...graph, | ||
| edges: [ | ||
| ...graph.edges, | ||
| { source: 'v2', sourceHandle: 'output-variable', target: 'imc', targetHandle: 'Moisture' }, | ||
| ], | ||
| } | ||
| expect(findOccupiedInOutPin({ target: 'imc', targetHandle: 'Moisture' }, busy)).toBeUndefined() | ||
| }) | ||
| }) | ||
|
|
||
| describe('migrating projects saved with a two-sided in-out pin', () => { | ||
| it('re-points a wire leaving the in-out pin at whatever feeds the pin', () => { | ||
| // The Irrigation Controller's main POU: variable `State` feeds the pin, and the pin is | ||
| // read into two other blocks. | ||
| const edges = [ | ||
| { source: 'stateVar', sourceHandle: 'output-variable', target: 'imc', targetHandle: 'State' }, | ||
| { source: 'imc', sourceHandle: 'State', target: 'manualOverride', targetHandle: 'State' }, | ||
| { source: 'imc', sourceHandle: 'State', target: 'stateToNum', targetHandle: 'State' }, | ||
| ] | ||
| const result = migrateInOutSourceEdges([blockNode()], edges) | ||
|
|
||
| expect(result.rewired).toBe(2) | ||
| expect(result.dropped).toBe(0) | ||
| expect(result.edges).toEqual([ | ||
| edges[0], | ||
| { source: 'stateVar', sourceHandle: 'output-variable', target: 'manualOverride', targetHandle: 'State' }, | ||
| { source: 'stateVar', sourceHandle: 'output-variable', target: 'stateToNum', targetHandle: 'State' }, | ||
| ]) | ||
| }) | ||
|
|
||
| it('drops a wire whose in-out pin has nothing feeding it', () => { | ||
| const result = migrateInOutSourceEdges( | ||
| [blockNode()], | ||
| [{ source: 'imc', sourceHandle: 'State', target: 'manualOverride', targetHandle: 'State' }], | ||
| ) | ||
| expect(result).toMatchObject({ edges: [], rewired: 0, dropped: 1 }) | ||
| }) | ||
|
|
||
| it('leaves a diagram without in-out pins untouched', () => { | ||
| const edges = [{ source: 'a', sourceHandle: 'Q', target: 'b', targetHandle: 'IN' }] | ||
| const plain = { ...blockNode(), data: { ...blockNode().data, variant: { name: 'TON', variables: [] } } } | ||
| expect(migrateInOutSourceEdges([plain], edges)).toEqual({ edges, rewired: 0, dropped: 0 }) | ||
| }) | ||
|
|
||
| it('removes the stale in-out output pin from the saved handles', () => { | ||
| const healed = stripInOutOutputHandles(blockNode(), { connectorY: 48, connectorOffsetY: 48 }) | ||
|
|
||
| expect(healed.data.outputHandles).toEqual([]) | ||
| expect(healed.data.handles?.map((h) => `${h.id}:${h.type}`)).toEqual([ | ||
| 'State:target', | ||
| 'Moisture:target', | ||
| 'T_Max:target', | ||
| ]) | ||
| expect(healed.data.outputConnector).toBeUndefined() | ||
| }) | ||
|
|
||
| it('re-flows the remaining output pins so labels and pins stay aligned', () => { | ||
| const node = blockNode() | ||
| node.data.variant.variables = [ | ||
| { name: 'Q', class: 'output', type: { definition: 'base-type', value: 'BOOL' } }, | ||
| ...variables, | ||
| ] | ||
| node.data.outputHandles = [handle('State', 'source', 48), handle('Q', 'source', 96)] | ||
| node.data.outputConnector = handle('State', 'source', 48) | ||
|
|
||
| const healed = stripInOutOutputHandles(node, { connectorY: 48, connectorOffsetY: 48 }) | ||
|
|
||
| // `Q` was second; with the in-out gone it moves up into the first slot. | ||
| expect(healed.data.outputHandles).toEqual([{ ...handle('Q', 'source', 48), glbPosition: { x: 0, y: 0 } }]) | ||
| expect(healed.data.outputConnector?.id).toBe('Q') | ||
| }) | ||
|
|
||
| it('is a no-op for a block that never had a two-sided in-out pin', () => { | ||
| const node = blockNode() | ||
| node.data.outputHandles = [] | ||
| node.data.handles = node.data.inputHandles | ||
| const healed = stripInOutOutputHandles(node, { connectorY: 48, connectorOffsetY: 48 }) | ||
| expect(healed).toBe(node) | ||
| }) | ||
| }) | ||
|
|
||
| describe('block width reserves room for the ⟷ marker', () => { | ||
| // The marker only moves the width when the in-out pin is the WIDEST label and the block is | ||
| // not already at the maximum width — otherwise the marker is free. | ||
| const wideInOut = [ | ||
| { name: 'StateRef', class: 'inOut', type: { definition: 'base-type', value: 'INT' } }, | ||
| { name: 'B', class: 'input', type: { definition: 'base-type', value: 'BOOL' } }, | ||
| ] | ||
| const asPlainInput = wideInOut.map((v) => (v.class === 'inOut' ? { ...v, class: 'input' } : v)) | ||
| const variant = (vars: typeof wideInOut) => | ||
| ({ name: 'FB', type: 'function-block', variables: vars, documentation: '', extensible: false }) as never | ||
|
|
||
| it('adds the marker width only for in-out pins', async () => { | ||
| const { getBlockSize } = await import('../fbd/utils/utils') | ||
| const at = { x: 0, y: 0 } | ||
| expect(getBlockSize(variant(wideInOut), at).width).toBe( | ||
| getBlockSize(variant(asPlainInput), at).width + IN_OUT_MARKER_WIDTH, | ||
| ) | ||
| }) | ||
|
|
||
| it('leaves a block whose widest pin is not the in-out unchanged', async () => { | ||
| const { getBlockSize } = await import('../fbd/utils/utils') | ||
| const at = { x: 0, y: 0 } | ||
| // `Moisture` is wider than `State ⟷`, so it still sets the width. | ||
| expect(getBlockSize(variant(variables as never), at).width).toBe( | ||
| getBlockSize(variant(variables.map((v) => (v.class === 'inOut' ? { ...v, class: 'input' } : v)) as never), at) | ||
| .width, | ||
| ) | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,3 @@ | ||
| import type { NodeProps } from '@xyflow/react' | ||
|
|
||
| import type { DiffStatus } from '../../../../../middleware/shared/ports/version-control-port' | ||
|
|
@@ -5,6 +5,7 @@ | |
| import { CommentVisual } from '../fbd/comment-visual' | ||
| import { ConnectionVisual } from '../fbd/connection-visual' | ||
| import { VariableVisual } from '../fbd/variable-visual' | ||
| import { blockInputVariables, blockOutputVariables } from '../in-out-pin-rules' | ||
| import { DiffWrapper, renderFBDHandles } from './diff-wrapper' | ||
|
|
||
| export function ReadOnlyFBDBlock({ data, width, height }: NodeProps) { | ||
|
|
@@ -15,8 +16,8 @@ | |
| const blockName = variant?.name ?? '???' | ||
| const blockType = variant?.type ?? '' | ||
| const blockVars = variant?.variables ?? [] | ||
| const inputs = blockVars.filter((v) => v.class === 'input' || v.class === 'inOut').map((v) => v.name) | ||
| const outputs = blockVars.filter((v) => v.class === 'output' || v.class === 'inOut').map((v) => v.name) | ||
| const inputs = blockInputVariables(blockVars).map((v) => v.name) | ||
| const outputs = blockOutputVariables(blockVars).map((v) => v.name) | ||
|
Comment on lines
+19
to
+20
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Render the in-out marker in both read-only block views.
📍 Affects 2 files
🤖 Prompt for AI Agents |
||
| const varName = (data.variable as { name?: string })?.name ?? '' | ||
| const showInstanceName = blockType !== 'function' && blockType !== 'generic' && varName | ||
| const w = (width as number) ?? 216 | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,33 @@ | ||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||
| * The ⟷ badge that marks a `VAR_IN_OUT` pin, drawn after the pin name (`State ⟷`). | ||||||||||||||||||||||||
| * | ||||||||||||||||||||||||
| * An in-out parameter has one pin, on the input side, so without a marker it is | ||||||||||||||||||||||||
| * indistinguishable from a plain input. CODESYS marks it the same way, with a left-right | ||||||||||||||||||||||||
| * arrow, which keeps the two editors readable in the same way for anyone moving between | ||||||||||||||||||||||||
| * them. | ||||||||||||||||||||||||
| * | ||||||||||||||||||||||||
| * It is drawn as an SVG rather than the `⟷` character: the glyph is missing from several of | ||||||||||||||||||||||||
| * the fonts the editors fall back to, and where it exists it sits on the baseline instead of | ||||||||||||||||||||||||
| * beside the pin name. The arrow is `w-3` (12px) and `ml-1` (4px) — together the | ||||||||||||||||||||||||
| * `IN_OUT_MARKER_WIDTH` that block sizing reserves, so a long in-out name plus the arrow | ||||||||||||||||||||||||
| * cannot overflow the block. | ||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||
| const InOutPinMarker = () => ( | ||||||||||||||||||||||||
| <span | ||||||||||||||||||||||||
| aria-label='in-out parameter' | ||||||||||||||||||||||||
| title='VAR_IN_OUT — passed by reference: the block writes back to this variable' | ||||||||||||||||||||||||
| className='pointer-events-none ml-1 inline-flex w-3 shrink-0 select-none items-center align-middle' | ||||||||||||||||||||||||
| > | ||||||||||||||||||||||||
|
Comment on lines
+16
to
+20
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Expose the marker to assistive technology. The Proposed fix <span
+ role='img'
aria-label='in-out parameter'📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||
| <svg viewBox='0 0 12 9' fill='none' className='h-[9px] w-3' aria-hidden='true'> | ||||||||||||||||||||||||
| <path | ||||||||||||||||||||||||
| d='M3.4 1.7 1.1 4.5l2.3 2.8M8.6 1.7l2.3 2.8-2.3 2.8M1.1 4.5h9.8' | ||||||||||||||||||||||||
| stroke='currentColor' | ||||||||||||||||||||||||
| strokeWidth='1.2' | ||||||||||||||||||||||||
| strokeLinecap='round' | ||||||||||||||||||||||||
| strokeLinejoin='round' | ||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||
| </svg> | ||||||||||||||||||||||||
| </span> | ||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| export { InOutPinMarker } | ||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
Repository: Autonomy-Logic/openplc-editor
Length of output: 50385
🏁 Script executed:
Repository: Autonomy-Logic/openplc-editor
Length of output: 33356
🏁 Script executed:
Repository: Autonomy-Logic/openplc-editor
Length of output: 392
Move FBD migration logic out of the components layer.
src/frontend/store/slices/fbd/slice.tsimports pure geometry constants and graph migration helpers fromfrontend/components. Move them to a store-allowed shared module, update consumers and tests, then remove theKNOWN_EXCEPTIONSentry.🤖 Prompt for AI Agents
Source: Learnings