mirror of
https://github.com/simstudioai/sim.git
synced 2026-08-30 17:05:18 +08:00
fix(workflow): hide idle nested subflow end handles (#6976)
* fix(workflow): hide idle nested subflow end handles * perf(workflow): avoid repeated subflow edge scans * perf(workflow): stabilize subflow edge selector --------- Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local> Co-authored-by: Waleed Latif <walif6@gmail.com>
This commit is contained in:
@@ -8,6 +8,7 @@ interface DocsContainerData {
|
||||
name: string
|
||||
blockType: string
|
||||
size?: { width: number; height: number }
|
||||
parentId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -24,6 +25,7 @@ export const DocsContainerNode = memo(function DocsContainerNode({
|
||||
name: data.name,
|
||||
width: data.size?.width,
|
||||
height: data.size?.height,
|
||||
parentId: data.parentId,
|
||||
isPreview: true,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { BLOCK_Z_BASE, CONTAINER_CHILD_Z_BASE, getEdgeZIndex } from '@sim/workflow-renderer'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { type PreviewBlock, type PreviewWorkflow, toReactFlowElements } from './workflow-data'
|
||||
|
||||
const block = (
|
||||
overrides: Partial<PreviewBlock> & Pick<PreviewBlock, 'id' | 'type'>
|
||||
): PreviewBlock => ({
|
||||
name: overrides.id,
|
||||
bgColor: '#000000',
|
||||
rows: [],
|
||||
position: { x: 0, y: 0 },
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const workflow: PreviewWorkflow = {
|
||||
id: 'nested-subflows',
|
||||
name: 'Nested subflows',
|
||||
blocks: [
|
||||
block({ id: 'start', type: 'starter' }),
|
||||
block({ id: 'loop', type: 'loop', size: { width: 500, height: 300 } }),
|
||||
block({
|
||||
id: 'parallel',
|
||||
type: 'parallel',
|
||||
parentId: 'loop',
|
||||
position: { x: 24, y: 64 },
|
||||
size: { width: 400, height: 200 },
|
||||
}),
|
||||
block({ id: 'agent', type: 'agent', parentId: 'loop', position: { x: 24, y: 140 } }),
|
||||
],
|
||||
edges: [
|
||||
{ id: 'start-loop', source: 'start', target: 'loop' },
|
||||
{ id: 'loop-parallel', source: 'loop', target: 'parallel' },
|
||||
{ id: 'loop-agent', source: 'loop', target: 'agent' },
|
||||
],
|
||||
}
|
||||
|
||||
describe('toReactFlowElements layering', () => {
|
||||
it('places incoming edges on their container target layer', () => {
|
||||
const { nodes, edges } = toReactFlowElements(workflow, false, {
|
||||
highlightEdge: 'loop-parallel',
|
||||
})
|
||||
const nodeById = new Map(nodes.map((node) => [node.id, node]))
|
||||
const edgeById = new Map(edges.map((edge) => [edge.id, edge]))
|
||||
|
||||
expect(nodeById.get('loop')?.zIndex).toBe(0)
|
||||
expect(nodeById.get('parallel')?.zIndex).toBe(1)
|
||||
expect(edgeById.get('start-loop')?.zIndex).toBe(0)
|
||||
expect(edgeById.get('loop-parallel')?.zIndex).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps ordinary cards above normally layered edges', () => {
|
||||
const { nodes, edges } = toReactFlowElements(workflow)
|
||||
const nodeById = new Map(nodes.map((node) => [node.id, node]))
|
||||
const edgeById = new Map(edges.map((edge) => [edge.id, edge]))
|
||||
|
||||
expect(nodeById.get('start')?.zIndex).toBe(BLOCK_Z_BASE)
|
||||
expect(nodeById.get('agent')?.zIndex).toBe(CONTAINER_CHILD_Z_BASE)
|
||||
expect(edgeById.get('loop-agent')?.zIndex).toBe(getEdgeZIndex(0))
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,9 @@
|
||||
import {
|
||||
BLOCK_Z_BASE,
|
||||
CONTAINER_CHILD_Z_BASE,
|
||||
getEdgeZIndex,
|
||||
getEdgeZIndexForTarget,
|
||||
} from '@sim/workflow-renderer'
|
||||
import { type Edge, type Node, Position } from 'reactflow'
|
||||
|
||||
/**
|
||||
@@ -61,6 +67,24 @@ export interface HighlightOptions {
|
||||
selectedBlock?: string
|
||||
}
|
||||
|
||||
/** Semantic container depth used for z-order while docs positions stay flattened. */
|
||||
function getNestingDepth(block: PreviewBlock, blocksById: Map<string, PreviewBlock>): number {
|
||||
let depth = 0
|
||||
let parentId = block.parentId
|
||||
const visited = new Set<string>()
|
||||
|
||||
while (parentId && !visited.has(parentId)) {
|
||||
const parent = blocksById.get(parentId)
|
||||
if (!parent) break
|
||||
|
||||
visited.add(parentId)
|
||||
depth += 1
|
||||
parentId = parent.parentId
|
||||
}
|
||||
|
||||
return depth
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a {@link PreviewWorkflow} to React Flow nodes and edges.
|
||||
*
|
||||
@@ -81,6 +105,7 @@ export function toReactFlowElements(
|
||||
|
||||
const nodes: Node[] = workflow.blocks.map((block, index) => {
|
||||
const isContainer = Boolean(block.size)
|
||||
const nestingDepth = getNestingDepth(block, blocksById)
|
||||
// Nested blocks are authored relative to their container; render them at
|
||||
// absolute coordinates (not React Flow parentNode children) so the edges
|
||||
// between a container and its nested blocks render reliably and on top.
|
||||
@@ -92,7 +117,7 @@ export function toReactFlowElements(
|
||||
id: block.id,
|
||||
type: isContainer ? 'previewContainer' : 'previewBlock',
|
||||
position,
|
||||
zIndex: isContainer ? 0 : 1,
|
||||
zIndex: isContainer ? nestingDepth : block.parentId ? CONTAINER_CHILD_Z_BASE : BLOCK_Z_BASE,
|
||||
...(block.size ? { style: { width: block.size.width, height: block.size.height } } : {}),
|
||||
data: {
|
||||
name: block.name,
|
||||
@@ -103,6 +128,7 @@ export function toReactFlowElements(
|
||||
tools: block.tools,
|
||||
hideTargetHandle: block.hideTargetHandle,
|
||||
size: block.size,
|
||||
parentId: block.parentId,
|
||||
index,
|
||||
animate,
|
||||
isHighlighted: highlightBlock === block.id || selectedBlock === block.id,
|
||||
@@ -127,6 +153,14 @@ export function toReactFlowElements(
|
||||
// so edges into and out of Loop/Parallel containers still connect.
|
||||
const sourceBlock = blocksById.get(e.source)
|
||||
const targetBlock = blocksById.get(e.target)
|
||||
const parentContainer = blocksById.get(sourceBlock?.parentId ?? targetBlock?.parentId ?? '')
|
||||
const baseZIndex = getEdgeZIndex(
|
||||
parentContainer ? getNestingDepth(parentContainer, blocksById) : undefined,
|
||||
{ isHighlighted: isEdgeHighlight }
|
||||
)
|
||||
const targetContainerZIndex = targetBlock?.size
|
||||
? getNestingDepth(targetBlock, blocksById)
|
||||
: undefined
|
||||
const sourceHandle =
|
||||
e.sourceHandle ?? (sourceBlock?.size ? `${sourceBlock.type}-end-source` : 'source')
|
||||
const targetHandle = targetBlock?.size ? undefined : 'target'
|
||||
@@ -142,6 +176,7 @@ export function toReactFlowElements(
|
||||
},
|
||||
sourceHandle,
|
||||
targetHandle,
|
||||
zIndex: getEdgeZIndexForTarget(baseZIndex, targetContainerZIndex),
|
||||
data: {
|
||||
animate,
|
||||
delay: animate ? sourceIndex * BLOCK_STAGGER + BLOCK_STAGGER : 0,
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"build": "fumadocs-mdx && NODE_OPTIONS='--max-old-space-size=8192' next build",
|
||||
"start": "next start",
|
||||
"postinstall": "fumadocs-mdx",
|
||||
"test": "vitest run",
|
||||
"type-check": "fumadocs-mdx && tsc --noEmit",
|
||||
"lint": "biome check --write --unsafe .",
|
||||
"lint:check": "biome check .",
|
||||
@@ -47,6 +48,7 @@
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^4.0.12",
|
||||
"typescript": "^7.0.2"
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
EDGE_Z_MAX,
|
||||
getBlockZIndex,
|
||||
getEdgeZIndex,
|
||||
getEdgeZIndexForTarget,
|
||||
getNoteBlockHeight,
|
||||
normalizeCursorSourceHandleId,
|
||||
} from '@sim/workflow-renderer'
|
||||
@@ -4887,10 +4888,16 @@ const WorkflowContent = React.memo(
|
||||
isEdgeSelected: isSelected,
|
||||
}),
|
||||
})
|
||||
const targetContainerZIndex =
|
||||
targetNode?.type === 'subflowNode' ? (targetNode.zIndex ?? 0) : undefined
|
||||
// The target node paints after an equal-z edge. A nested container is
|
||||
// one depth above its parent, so this hides only the segment beneath
|
||||
// the target while leaving the route visible over the parent body.
|
||||
const zIndex = getEdgeZIndexForTarget(baseZIndex, targetContainerZIndex)
|
||||
|
||||
return {
|
||||
...edge,
|
||||
zIndex: baseZIndex,
|
||||
zIndex,
|
||||
data: {
|
||||
...edge.data,
|
||||
isSelected,
|
||||
@@ -4899,6 +4906,7 @@ const WorkflowContent = React.memo(
|
||||
parentLoopId,
|
||||
sourceHandle: edge.sourceHandle,
|
||||
onDelete: handleEdgeDelete,
|
||||
...(targetContainerZIndex !== undefined ? { labelZIndex: zIndex } : {}),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
+1
@@ -12,6 +12,7 @@ interface WorkflowPreviewSubflowData {
|
||||
width?: number
|
||||
height?: number
|
||||
kind: 'loop' | 'parallel'
|
||||
parentId?: string
|
||||
/** Whether this subflow is enabled */
|
||||
enabled?: boolean
|
||||
/** Whether this subflow is selected in preview mode */
|
||||
|
||||
+13
-2
@@ -22,6 +22,7 @@ import {
|
||||
CONTAINER_DIMENSIONS,
|
||||
EDGE_Z_BASE,
|
||||
EDGE_Z_MAX,
|
||||
getEdgeZIndexForTarget,
|
||||
} from '@sim/workflow-renderer'
|
||||
import { normalizeWorkflowEdgeHandles } from '@sim/workflow-types/workflow'
|
||||
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
|
||||
@@ -567,6 +568,14 @@ export function PreviewWorkflow({
|
||||
return normalizeWorkflowEdgeHandles(workflowState.edges).map((edge) => {
|
||||
const status = getEdgeExecutionStatus(edge)
|
||||
const isErrorEdge = edge.sourceHandle === 'error'
|
||||
const baseZIndex =
|
||||
status === 'success' ? EDGE_Z_MAX : isErrorEdge ? EDGE_Z_BASE + 2 : EDGE_Z_BASE
|
||||
const targetBlock = workflowState.blocks[edge.target]
|
||||
const targetContainerZIndex =
|
||||
targetBlock?.type === 'loop' || targetBlock?.type === 'parallel'
|
||||
? calculateNestingDepth(targetBlock, workflowState.blocks)
|
||||
: undefined
|
||||
|
||||
return {
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
@@ -580,12 +589,14 @@ export function PreviewWorkflow({
|
||||
/* Inside the shared edge band, so a line clears the opaque container it
|
||||
crosses and still passes behind cards. Execution status orders edges
|
||||
within the band: a successful path draws over an error one, which
|
||||
draws over an unexecuted one. */
|
||||
zIndex: status === 'success' ? EDGE_Z_MAX : isErrorEdge ? EDGE_Z_BASE + 2 : EDGE_Z_BASE,
|
||||
draws over an unexecuted one. A Loop/Parallel target overrides that
|
||||
ordering so its node paints over the incoming segment. */
|
||||
zIndex: getEdgeZIndexForTarget(baseZIndex, targetContainerZIndex),
|
||||
}
|
||||
})
|
||||
}, [
|
||||
edgesStructure,
|
||||
workflowState.blocks,
|
||||
workflowState.edges,
|
||||
isValidWorkflowState,
|
||||
blockExecutionMap,
|
||||
|
||||
@@ -93,6 +93,7 @@
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^4.0.12",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^4.1.0",
|
||||
},
|
||||
},
|
||||
"apps/pii": {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
EDGE_Z_MAX,
|
||||
getBlockZIndex,
|
||||
getEdgeZIndex,
|
||||
getEdgeZIndexForTarget,
|
||||
} from './canvas-layers'
|
||||
|
||||
/**
|
||||
@@ -69,3 +70,36 @@ describe('getEdgeZIndex', () => {
|
||||
expect(getEdgeZIndex(8)).toBeLessThan(getEdgeZIndex(undefined, { isHighlighted: true }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('getEdgeZIndexForTarget', () => {
|
||||
it('shares a container target layer so the node paints over the incoming edge', () => {
|
||||
const parentZIndex = 0
|
||||
const targetZIndex = 1
|
||||
const edgeZIndex = getEdgeZIndex(parentZIndex)
|
||||
|
||||
const resolved = getEdgeZIndexForTarget(edgeZIndex, targetZIndex)
|
||||
|
||||
expect(resolved).toBe(targetZIndex)
|
||||
expect(resolved).toBeGreaterThan(parentZIndex)
|
||||
})
|
||||
|
||||
it('places incoming edges beneath top-level container targets', () => {
|
||||
expect(getEdgeZIndexForTarget(EDGE_Z_BASE, 0)).toBe(0)
|
||||
})
|
||||
|
||||
it('does not let highlighting elevate an edge over its container target', () => {
|
||||
const highlighted = getEdgeZIndex(undefined, { isHighlighted: true })
|
||||
|
||||
expect(getEdgeZIndexForTarget(highlighted, 2)).toBe(2)
|
||||
})
|
||||
|
||||
it('does not let an execution edge elevate over its container target', () => {
|
||||
expect(getEdgeZIndexForTarget(EDGE_Z_MAX, 2)).toBe(2)
|
||||
})
|
||||
|
||||
it('leaves edges to ordinary blocks unchanged', () => {
|
||||
const edgeZIndex = getEdgeZIndex(1)
|
||||
|
||||
expect(getEdgeZIndexForTarget(edgeZIndex, undefined)).toBe(edgeZIndex)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,12 +9,15 @@
|
||||
* - {@link CONTAINER_CHILD_Z_BASE} — cards inside a container (same +1 / +10 steps)
|
||||
* - {@link CONNECTION_PICKER_Z} — the connection block picker
|
||||
*
|
||||
* Containers and edges must occupy separate bands. A container paints an opaque
|
||||
* body, so an edge sharing its z loses the equal-z tiebreak to DOM order — React
|
||||
* Flow renders the nodes layer after the edges layer — and is drawn *behind* the
|
||||
* container. That is what hid every line crossing a top-level subflow, whether
|
||||
* in flight or persisted. Cards then sit above the edge band, so a line still
|
||||
* passes behind card chrome, knobs, and the action-bar swell.
|
||||
* Containers and ordinary edges occupy separate bands. A container paints an
|
||||
* opaque body, so an edge sharing its z loses the equal-z tiebreak to DOM order
|
||||
* — React Flow renders the nodes layer after the edges layer — and is drawn
|
||||
* *behind* the container. Incoming container edges deliberately use that rule
|
||||
* at their target's depth: the target sits above its parent by one depth, leaving
|
||||
* the edge over the parent body but beneath the target. The edge can also be
|
||||
* occluded by peer or higher-depth containers it crosses. Cards then sit above
|
||||
* the edge band, so every other line still passes behind card chrome, knobs, and
|
||||
* the action-bar swell.
|
||||
*
|
||||
* Shared by the editor canvas and the read-only preview because both render the
|
||||
* same graph through the same React Flow layering rules; a second scale drifted
|
||||
@@ -74,3 +77,22 @@ export function getEdgeZIndex(
|
||||
const depth = containerZIndex === undefined ? 0 : containerZIndex + 1
|
||||
return Math.min(EDGE_Z_BASE + depth, EDGE_Z_DEPTH_MAX)
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps an incoming edge beneath a Loop/Parallel target without hiding it
|
||||
* behind that target's parent.
|
||||
*
|
||||
* Containers use their nesting depth as z-index, so a nested target is exactly
|
||||
* one layer above its parent. React Flow renders equal-z edges before nodes;
|
||||
* sharing the target's layer therefore leaves the edge visible over the parent
|
||||
* body while the target paints over the segment that reaches beneath it.
|
||||
*
|
||||
* `targetContainerZIndex` must only be supplied when the edge targets a
|
||||
* container. Ordinary edges retain their existing depth/highlight ordering.
|
||||
*/
|
||||
export function getEdgeZIndexForTarget(
|
||||
edgeZIndex: number,
|
||||
targetContainerZIndex: number | undefined
|
||||
): number {
|
||||
return targetContainerZIndex ?? edgeZIndex
|
||||
}
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { act } from 'react'
|
||||
import { act, type ReactNode } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { Position } from 'reactflow'
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { WorkflowEdgeView, type WorkflowEdgeViewProps } from '../index'
|
||||
|
||||
vi.mock('reactflow', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('reactflow')>()
|
||||
return {
|
||||
...actual,
|
||||
EdgeLabelRenderer: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
}
|
||||
})
|
||||
|
||||
const mountedHosts = new Set<HTMLDivElement>()
|
||||
const mountedRoots = new Set<Root>()
|
||||
|
||||
@@ -210,4 +218,12 @@ describe('WorkflowEdgeView', () => {
|
||||
|
||||
expect(path?.style.stroke).toBe('var(--text-error)')
|
||||
})
|
||||
|
||||
it('keeps the selected-edge control on a container target occlusion layer', () => {
|
||||
const { host } = renderEdge({
|
||||
data: { isSelected: true, labelZIndex: 1 },
|
||||
})
|
||||
|
||||
expect(host.querySelector('button')).toHaveStyle({ zIndex: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { EdgeDiffStatus, EdgeRunStatus } from '../types'
|
||||
const EXECUTION_PULSE_LENGTH = 0.32
|
||||
const EXECUTION_PULSE_CYCLE_LENGTH = 2.2
|
||||
const EXECUTION_PULSE_DURATION = '1100ms'
|
||||
const DEFAULT_EDGE_LABEL_Z_INDEX = 1011
|
||||
|
||||
/**
|
||||
* How far the glow reaches past the path, in user space.
|
||||
@@ -125,6 +126,8 @@ export function WorkflowEdgeView({
|
||||
}, [isHorizontal, sourceX, sourceY, targetX, targetY])
|
||||
|
||||
const isSelected = data?.isSelected ?? false
|
||||
const labelZIndex =
|
||||
(data as { labelZIndex?: number } | undefined)?.labelZIndex ?? DEFAULT_EDGE_LABEL_Z_INDEX
|
||||
|
||||
const dataSourceHandle = (data as { sourceHandle?: string } | undefined)?.sourceHandle
|
||||
const isErrorEdge = (sourceHandle ?? dataSourceHandle) === 'error'
|
||||
@@ -268,7 +271,7 @@ export function WorkflowEdgeView({
|
||||
position: 'absolute',
|
||||
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
|
||||
pointerEvents: 'all',
|
||||
zIndex: 1011,
|
||||
zIndex: labelZIndex,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
|
||||
@@ -6,6 +6,7 @@ export {
|
||||
EDGE_Z_MAX,
|
||||
getBlockZIndex,
|
||||
getEdgeZIndex,
|
||||
getEdgeZIndexForTarget,
|
||||
} from './canvas-layers'
|
||||
export * from './dimensions'
|
||||
export { WorkflowEdgeView, type WorkflowEdgeViewProps } from './edge/workflow-edge-view'
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
Handle,
|
||||
internalsSymbol,
|
||||
Position,
|
||||
type ReactFlowState,
|
||||
useStore as useReactFlowStore,
|
||||
useStoreApi as useReactFlowStoreApi,
|
||||
useUpdateNodeInternals,
|
||||
} from 'reactflow'
|
||||
@@ -343,6 +345,22 @@ export function SubflowNodeView({
|
||||
const isPreviewSelected = data?.isPreviewSelected || false
|
||||
|
||||
const endHandleId = data.kind === 'loop' ? 'loop-end-source' : 'parallel-end-source'
|
||||
const showFixedEndPort = useReactFlowStore(
|
||||
useMemo(() => {
|
||||
let previousEdges: ReactFlowState['edges'] | undefined
|
||||
let previousResult = !data.parentId
|
||||
|
||||
return (state: ReactFlowState) => {
|
||||
if (!data.parentId || state.edges === previousEdges) return previousResult
|
||||
|
||||
previousEdges = state.edges
|
||||
previousResult = state.edges.some(
|
||||
(edge) => edge.source === id && edge.sourceHandle === endHandleId
|
||||
)
|
||||
return previousResult
|
||||
}
|
||||
}, [data.parentId, endHandleId, id])
|
||||
)
|
||||
const BlockIcon = data.kind === 'loop' ? Repeat : Split
|
||||
const blockName = data.name || (data.kind === 'loop' ? 'Loop' : 'Parallel')
|
||||
const blockTypeLabel = data.kind === 'loop' ? 'Loop' : 'Parallel'
|
||||
@@ -474,13 +492,16 @@ export function SubflowNodeView({
|
||||
position: HANDLE_POSITIONS.SUBFLOW_CONNECTION_Y,
|
||||
plateau: CURSOR_SWELL_LENGTH_PX,
|
||||
},
|
||||
{
|
||||
]
|
||||
|
||||
if (showFixedEndPort) {
|
||||
ports.push({
|
||||
id: endHandleId,
|
||||
side: 'right',
|
||||
position: HANDLE_POSITIONS.SUBFLOW_CONNECTION_Y,
|
||||
plateau: CURSOR_SWELL_LENGTH_PX,
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
if (showActionMenu) {
|
||||
ports.push({
|
||||
@@ -495,7 +516,7 @@ export function SubflowNodeView({
|
||||
}
|
||||
|
||||
return ports
|
||||
}, [actionMenuSwellOpen, actionMenuWidth, endHandleId, showActionMenu])
|
||||
}, [actionMenuSwellOpen, actionMenuWidth, endHandleId, showActionMenu, showFixedEndPort])
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
+145
-2
@@ -5,13 +5,13 @@
|
||||
* a knob-paint bug once threw only when a card had a coloured knob — invisible
|
||||
* on an idle canvas, fatal on node creation.
|
||||
*/
|
||||
import { act } from 'react'
|
||||
import { act, Profiler, useLayoutEffect } from 'react'
|
||||
import {
|
||||
normalizeWorkflowEdgeSourceHandle,
|
||||
normalizeWorkflowEdgeTargetHandle,
|
||||
} from '@sim/workflow-types/workflow'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { ReactFlowProvider } from 'reactflow'
|
||||
import { type Edge, ReactFlowProvider, useStoreApi as useReactFlowStoreApi } from 'reactflow'
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { CONTAINER_DIMENSIONS } from '../dimensions'
|
||||
import {
|
||||
@@ -83,6 +83,55 @@ function mount(element: React.ReactElement) {
|
||||
return { host, root }
|
||||
}
|
||||
|
||||
function SubflowMountFixture({
|
||||
id,
|
||||
kind,
|
||||
parentId,
|
||||
edges,
|
||||
onRender,
|
||||
}: {
|
||||
id: string
|
||||
kind: 'loop' | 'parallel'
|
||||
parentId?: string
|
||||
edges?: Edge[]
|
||||
onRender?: () => void
|
||||
}) {
|
||||
const reactFlowStore = useReactFlowStoreApi()
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (edges) reactFlowStore.setState({ edges })
|
||||
}, [edges, reactFlowStore])
|
||||
|
||||
const view = (
|
||||
<SubflowNodeView
|
||||
id={id}
|
||||
data={{ kind, name: kind === 'loop' ? 'Loop' : 'Parallel', parentId, isPreview: true }}
|
||||
isEnabled
|
||||
isLocked={false}
|
||||
isFocused={false}
|
||||
nestingLevel={parentId ? 1 : 0}
|
||||
canEditWorkflow={false}
|
||||
onSelect={() => undefined}
|
||||
/>
|
||||
)
|
||||
|
||||
return onRender ? (
|
||||
<Profiler id={id} onRender={onRender}>
|
||||
{view}
|
||||
</Profiler>
|
||||
) : (
|
||||
view
|
||||
)
|
||||
}
|
||||
|
||||
function getSubflowSilhouette(host: HTMLElement, caseName: string) {
|
||||
const path = host.querySelector<SVGPathElement>(
|
||||
`[data-subflow-case="${caseName}"] [data-type="subflowNode"] > svg > path[fill="var(--border-1)"]`
|
||||
)
|
||||
expect(path).toBeTruthy()
|
||||
return path?.getAttribute('d')
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
mountedRoots.forEach((root) => root.unmount())
|
||||
@@ -980,6 +1029,100 @@ describe('WorkflowBlockBorder mount', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['loop', 'parallel'] as const)(
|
||||
'only paints the fixed %s end port when its topology needs it',
|
||||
(kind) => {
|
||||
const endHandleId = `${kind}-end-source`
|
||||
const nestedConnectedEdges: Edge[] = [
|
||||
{
|
||||
id: `${kind}-end-edge`,
|
||||
source: `${kind}-nested-connected`,
|
||||
sourceHandle: endHandleId,
|
||||
target: `${kind}-sibling`,
|
||||
targetHandle: 'target',
|
||||
},
|
||||
]
|
||||
const { host } = mount(
|
||||
<div>
|
||||
<ReactFlowProvider>
|
||||
<div data-subflow-case='top-level'>
|
||||
<SubflowMountFixture id={`${kind}-top-level`} kind={kind} />
|
||||
</div>
|
||||
</ReactFlowProvider>
|
||||
<ReactFlowProvider>
|
||||
<div data-subflow-case='nested-idle'>
|
||||
<SubflowMountFixture
|
||||
id={`${kind}-nested-idle`}
|
||||
kind={kind}
|
||||
parentId={`${kind}-parent`}
|
||||
/>
|
||||
</div>
|
||||
</ReactFlowProvider>
|
||||
<ReactFlowProvider>
|
||||
<div data-subflow-case='nested-connected'>
|
||||
<SubflowMountFixture
|
||||
id={`${kind}-nested-connected`}
|
||||
kind={kind}
|
||||
parentId={`${kind}-parent`}
|
||||
edges={nestedConnectedEdges}
|
||||
/>
|
||||
</div>
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
)
|
||||
|
||||
const topLevelPath = getSubflowSilhouette(host, 'top-level')
|
||||
const nestedIdlePath = getSubflowSilhouette(host, 'nested-idle')
|
||||
const nestedConnectedPath = getSubflowSilhouette(host, 'nested-connected')
|
||||
|
||||
expect(nestedIdlePath).not.toBe(topLevelPath)
|
||||
expect(nestedConnectedPath).toBe(topLevelPath)
|
||||
for (const caseName of ['top-level', 'nested-idle', 'nested-connected']) {
|
||||
const subflow = host.querySelector(`[data-subflow-case="${caseName}"]`)
|
||||
expect(subflow?.querySelector('[data-handleid="target"]')).toBeTruthy()
|
||||
expect(subflow?.querySelector(`[data-handleid="${endHandleId}"]`)).toBeTruthy()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('does not rerender a nested subflow when unrelated edges change', () => {
|
||||
const baselineRender = vi.fn()
|
||||
const unrelatedEdgeRender = vi.fn()
|
||||
const unrelatedEdges: Edge[] = [
|
||||
{
|
||||
id: 'unrelated-edge',
|
||||
source: 'other-source',
|
||||
sourceHandle: 'source',
|
||||
target: 'other-target',
|
||||
targetHandle: 'target',
|
||||
},
|
||||
]
|
||||
|
||||
mount(
|
||||
<div>
|
||||
<ReactFlowProvider>
|
||||
<SubflowMountFixture
|
||||
id='baseline-nested-loop'
|
||||
kind='loop'
|
||||
parentId='parent-loop'
|
||||
onRender={baselineRender}
|
||||
/>
|
||||
</ReactFlowProvider>
|
||||
<ReactFlowProvider>
|
||||
<SubflowMountFixture
|
||||
id='unrelated-edge-nested-loop'
|
||||
kind='loop'
|
||||
parentId='parent-loop'
|
||||
edges={unrelatedEdges}
|
||||
onRender={unrelatedEdgeRender}
|
||||
/>
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
)
|
||||
|
||||
expect(unrelatedEdgeRender).toHaveBeenCalledTimes(baselineRender.mock.calls.length)
|
||||
})
|
||||
|
||||
it('retracts a selected loop action swell after hover ends', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.stubGlobal(
|
||||
|
||||
Reference in New Issue
Block a user