Skip to content

Commit 8d86117

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(workflow): hide idle nested subflow end handles
1 parent 87ceaf2 commit 8d86117

5 files changed

Lines changed: 119 additions & 6 deletions

File tree

apps/docs/components/workflow-preview/docs-container-node.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ interface DocsContainerData {
88
name: string
99
blockType: string
1010
size?: { width: number; height: number }
11+
parentId?: string
1112
}
1213

1314
/**
@@ -24,6 +25,7 @@ export const DocsContainerNode = memo(function DocsContainerNode({
2425
name: data.name,
2526
width: data.size?.width,
2627
height: data.size?.height,
28+
parentId: data.parentId,
2729
isPreview: true,
2830
}
2931

apps/docs/components/workflow-preview/workflow-data.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ export function toReactFlowElements(
103103
tools: block.tools,
104104
hideTargetHandle: block.hideTargetHandle,
105105
size: block.size,
106+
parentId: block.parentId,
106107
index,
107108
animate,
108109
isHighlighted: highlightBlock === block.id || selectedBlock === block.id,

apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/subflow/subflow.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ interface WorkflowPreviewSubflowData {
1212
width?: number
1313
height?: number
1414
kind: 'loop' | 'parallel'
15+
parentId?: string
1516
/** Whether this subflow is enabled */
1617
enabled?: boolean
1718
/** Whether this subflow is selected in preview mode */

packages/workflow-renderer/src/subflow/subflow-node-view.tsx

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
Handle,
66
internalsSymbol,
77
Position,
8+
useStore as useReactFlowStore,
89
useStoreApi as useReactFlowStoreApi,
910
useUpdateNodeInternals,
1011
} from 'reactflow'
@@ -343,6 +344,14 @@ export function SubflowNodeView({
343344
const isPreviewSelected = data?.isPreviewSelected || false
344345

345346
const endHandleId = data.kind === 'loop' ? 'loop-end-source' : 'parallel-end-source'
347+
const hasDisplayedEndEdge = useReactFlowStore(
348+
useCallback(
349+
(state) =>
350+
state.edges.some((edge) => edge.source === id && edge.sourceHandle === endHandleId),
351+
[endHandleId, id]
352+
)
353+
)
354+
const showFixedEndPort = !data.parentId || hasDisplayedEndEdge
346355
const BlockIcon = data.kind === 'loop' ? Repeat : Split
347356
const blockName = data.name || (data.kind === 'loop' ? 'Loop' : 'Parallel')
348357
const blockTypeLabel = data.kind === 'loop' ? 'Loop' : 'Parallel'
@@ -474,13 +483,16 @@ export function SubflowNodeView({
474483
position: HANDLE_POSITIONS.SUBFLOW_CONNECTION_Y,
475484
plateau: CURSOR_SWELL_LENGTH_PX,
476485
},
477-
{
486+
]
487+
488+
if (showFixedEndPort) {
489+
ports.push({
478490
id: endHandleId,
479491
side: 'right',
480492
position: HANDLE_POSITIONS.SUBFLOW_CONNECTION_Y,
481493
plateau: CURSOR_SWELL_LENGTH_PX,
482-
},
483-
]
494+
})
495+
}
484496

485497
if (showActionMenu) {
486498
ports.push({
@@ -495,7 +507,7 @@ export function SubflowNodeView({
495507
}
496508

497509
return ports
498-
}, [actionMenuSwellOpen, actionMenuWidth, endHandleId, showActionMenu])
510+
}, [actionMenuSwellOpen, actionMenuWidth, endHandleId, showActionMenu, showFixedEndPort])
499511

500512
return (
501513
<div

packages/workflow-renderer/src/workflow-block/workflow-block-border-mount.test.tsx

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,13 @@
55
* a knob-paint bug once threw only when a card had a coloured knob — invisible
66
* on an idle canvas, fatal on node creation.
77
*/
8-
import { act } from 'react'
8+
import { act, useLayoutEffect } from 'react'
99
import {
1010
normalizeWorkflowEdgeSourceHandle,
1111
normalizeWorkflowEdgeTargetHandle,
1212
} from '@sim/workflow-types/workflow'
1313
import { createRoot, type Root } from 'react-dom/client'
14-
import { ReactFlowProvider } from 'reactflow'
14+
import { type Edge, ReactFlowProvider, useStoreApi as useReactFlowStoreApi } from 'reactflow'
1515
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
1616
import { CONTAINER_DIMENSIONS } from '../dimensions'
1717
import {
@@ -83,6 +83,47 @@ function mount(element: React.ReactElement) {
8383
return { host, root }
8484
}
8585

86+
const NO_EDGES: Edge[] = []
87+
88+
function SubflowMountFixture({
89+
id,
90+
kind,
91+
parentId,
92+
edges = NO_EDGES,
93+
}: {
94+
id: string
95+
kind: 'loop' | 'parallel'
96+
parentId?: string
97+
edges?: Edge[]
98+
}) {
99+
const reactFlowStore = useReactFlowStoreApi()
100+
101+
useLayoutEffect(() => {
102+
reactFlowStore.setState({ edges })
103+
}, [edges, reactFlowStore])
104+
105+
return (
106+
<SubflowNodeView
107+
id={id}
108+
data={{ kind, name: kind === 'loop' ? 'Loop' : 'Parallel', parentId, isPreview: true }}
109+
isEnabled
110+
isLocked={false}
111+
isFocused={false}
112+
nestingLevel={parentId ? 1 : 0}
113+
canEditWorkflow={false}
114+
onSelect={() => undefined}
115+
/>
116+
)
117+
}
118+
119+
function getSubflowSilhouette(host: HTMLElement, caseName: string) {
120+
const path = host.querySelector<SVGPathElement>(
121+
`[data-subflow-case="${caseName}"] [data-type="subflowNode"] > svg > path[fill="var(--border-1)"]`
122+
)
123+
expect(path).toBeTruthy()
124+
return path?.getAttribute('d')
125+
}
126+
86127
afterEach(() => {
87128
act(() => {
88129
mountedRoots.forEach((root) => root.unmount())
@@ -980,6 +1021,62 @@ describe('WorkflowBlockBorder mount', () => {
9801021
)
9811022
})
9821023

1024+
it.each(['loop', 'parallel'] as const)(
1025+
'only paints the fixed %s end port when its topology needs it',
1026+
(kind) => {
1027+
const endHandleId = `${kind}-end-source`
1028+
const nestedConnectedEdges: Edge[] = [
1029+
{
1030+
id: `${kind}-end-edge`,
1031+
source: `${kind}-nested-connected`,
1032+
sourceHandle: endHandleId,
1033+
target: `${kind}-sibling`,
1034+
targetHandle: 'target',
1035+
},
1036+
]
1037+
const { host } = mount(
1038+
<div>
1039+
<ReactFlowProvider>
1040+
<div data-subflow-case='top-level'>
1041+
<SubflowMountFixture id={`${kind}-top-level`} kind={kind} />
1042+
</div>
1043+
</ReactFlowProvider>
1044+
<ReactFlowProvider>
1045+
<div data-subflow-case='nested-idle'>
1046+
<SubflowMountFixture
1047+
id={`${kind}-nested-idle`}
1048+
kind={kind}
1049+
parentId={`${kind}-parent`}
1050+
/>
1051+
</div>
1052+
</ReactFlowProvider>
1053+
<ReactFlowProvider>
1054+
<div data-subflow-case='nested-connected'>
1055+
<SubflowMountFixture
1056+
id={`${kind}-nested-connected`}
1057+
kind={kind}
1058+
parentId={`${kind}-parent`}
1059+
edges={nestedConnectedEdges}
1060+
/>
1061+
</div>
1062+
</ReactFlowProvider>
1063+
</div>
1064+
)
1065+
1066+
const topLevelPath = getSubflowSilhouette(host, 'top-level')
1067+
const nestedIdlePath = getSubflowSilhouette(host, 'nested-idle')
1068+
const nestedConnectedPath = getSubflowSilhouette(host, 'nested-connected')
1069+
1070+
expect(nestedIdlePath).not.toBe(topLevelPath)
1071+
expect(nestedConnectedPath).toBe(topLevelPath)
1072+
for (const caseName of ['top-level', 'nested-idle', 'nested-connected']) {
1073+
const subflow = host.querySelector(`[data-subflow-case="${caseName}"]`)
1074+
expect(subflow?.querySelector('[data-handleid="target"]')).toBeTruthy()
1075+
expect(subflow?.querySelector(`[data-handleid="${endHandleId}"]`)).toBeTruthy()
1076+
}
1077+
}
1078+
)
1079+
9831080
it('retracts a selected loop action swell after hover ends', () => {
9841081
vi.useFakeTimers()
9851082
vi.stubGlobal(

0 commit comments

Comments
 (0)