{displayState !== 'hidden' && (
@@ -240,6 +242,16 @@ const NodeToolbarComponent = ({
)}
);
+
+ if (portalToNodeOverlay) {
+ return (
+
+ {toolbarContent}
+
+ );
+ }
+
+ return toolbarContent;
};
export const NodeToolbar = memo(NodeToolbarComponent);
diff --git a/packages/apollo-react/src/canvas/components/Toolbar/NodeToolbar/NodeToolbar.types.ts b/packages/apollo-react/src/canvas/components/Toolbar/NodeToolbar/NodeToolbar.types.ts
index b6d406eee..05d68acb9 100644
--- a/packages/apollo-react/src/canvas/components/Toolbar/NodeToolbar/NodeToolbar.types.ts
+++ b/packages/apollo-react/src/canvas/components/Toolbar/NodeToolbar/NodeToolbar.types.ts
@@ -16,4 +16,6 @@ export interface NodeToolbarProps {
hidden?: boolean;
/** When true, push the toolbar further from the node to clear handle buttons. */
offsetToolbar?: boolean;
+ /** Render the toolbar in the node overlay layer instead of inside the node wrapper. */
+ portalToNodeOverlay?: boolean;
}
diff --git a/packages/apollo-react/src/canvas/components/index.ts b/packages/apollo-react/src/canvas/components/index.ts
index 66f753433..cfb7e70f3 100644
--- a/packages/apollo-react/src/canvas/components/index.ts
+++ b/packages/apollo-react/src/canvas/components/index.ts
@@ -10,6 +10,7 @@ export * from './ExecutionStatusIcon';
export * from './FloatingCanvasPanel';
export * from './GroupNode';
export * from './HierarchicalCanvas';
+export * from './LoopNode';
export * from './MiniCanvasNavigator';
export * from './NodeContextMenu';
export * from './NodeInspector';
diff --git a/packages/apollo-react/src/canvas/constants.ts b/packages/apollo-react/src/canvas/constants.ts
index 555998791..f087178d1 100644
--- a/packages/apollo-react/src/canvas/constants.ts
+++ b/packages/apollo-react/src/canvas/constants.ts
@@ -1,9 +1,14 @@
export const PREVIEW_NODE_ID = 'preview-node-id';
export const PREVIEW_EDGE_ID = 'preview-edge-id';
+export const DEFAULT_SOURCE_HANDLE_ID = 'output';
export const DEFAULT_NODE_SIZE = 96; // px
export const GRID_SPACING = 16;
+/** Default intrinsic size for container nodes (grid-aligned). */
+export const DEFAULT_CONTAINER_WIDTH = GRID_SPACING * 35; // 560px
+export const DEFAULT_CONTAINER_HEIGHT = GRID_SPACING * 20; // 320px
+
/** Canvas viewport width below which compact layout is used. */
export const CANVAS_COMPACT_BREAKPOINT = 600;
diff --git a/packages/apollo-react/src/canvas/hooks/useAddNodeOnConnectEnd.test.ts b/packages/apollo-react/src/canvas/hooks/useAddNodeOnConnectEnd.test.ts
index 88dbd26bb..ffc51f552 100644
--- a/packages/apollo-react/src/canvas/hooks/useAddNodeOnConnectEnd.test.ts
+++ b/packages/apollo-react/src/canvas/hooks/useAddNodeOnConnectEnd.test.ts
@@ -17,16 +17,14 @@ vi.mock('@uipath/apollo-react/canvas/xyflow/react', async () => ({
}));
vi.mock('../utils', () => ({
- applyPreviewToReactFlow: vi.fn(),
- createPreviewNode: vi.fn(),
+ showPreviewGraph: vi.fn(),
}));
import * as utils from '../utils';
// Import after mocks are set up
import { useAddNodeOnConnectEnd } from './useAddNodeOnConnectEnd';
-const mockApplyPreviewToReactFlow = vi.mocked(utils.applyPreviewToReactFlow);
-const mockCreatePreviewNode = vi.mocked(utils.createPreviewNode);
+const mockShowPreviewGraph = vi.mocked(utils.showPreviewGraph);
describe('useAddNodeOnConnectEnd', () => {
const mockFromNode = {
@@ -85,47 +83,31 @@ describe('useAddNodeOnConnectEnd', () => {
result.current({} as MouseEvent, connectionStateBase);
- expect(mockCreatePreviewNode).not.toHaveBeenCalled();
- expect(mockApplyPreviewToReactFlow).not.toHaveBeenCalled();
+ expect(mockShowPreviewGraph).not.toHaveBeenCalled();
});
- it('should create and apply preview node when connection ends on empty space', () => {
- const mockPreview = {
- node: { id: 'preview-node', position: { x: 100, y: 100 }, data: {} },
- edge: { id: 'preview-edge', source: 'node-1', target: 'preview-node' },
- };
-
+ it('should show preview graph when connection ends on empty space', () => {
const mockFlowPosition = { x: 100, y: 100 };
mockReactFlowInstance.screenToFlowPosition = vi.fn().mockReturnValue(mockFlowPosition);
- mockCreatePreviewNode.mockReturnValue(mockPreview);
const { result } = renderHook(() => useAddNodeOnConnectEnd());
result.current(mockMouseEvent, connectionStateBase);
expect(mockReactFlowInstance.screenToFlowPosition).toHaveBeenCalledWith({ x: 200, y: 200 });
- expect(mockCreatePreviewNode).toHaveBeenCalledWith(
- 'node-1',
- 'mock-output',
- mockReactFlowInstance,
- mockFlowPosition,
- undefined,
- 'source',
- undefined, // previewNodeSize (uses default)
- Position.Right, // handlePosition from connectionState.fromHandle.position
- [] // ignoredNodeTypes
- );
- expect(mockApplyPreviewToReactFlow).toHaveBeenCalledWith(mockPreview, mockReactFlowInstance);
+ expect(mockShowPreviewGraph).toHaveBeenCalledWith({
+ sourceNodeId: 'node-1',
+ sourceHandleId: 'mock-output',
+ reactFlowInstance: mockReactFlowInstance,
+ position: mockFlowPosition,
+ sourceHandleType: 'source',
+ handlePosition: Position.Right,
+ ignoredNodeTypes: [],
+ });
});
it("should use handle id if provided, default to 'output' if not", () => {
- const mockPreview = {
- node: { id: 'preview-node', position: { x: 100, y: 100 }, data: {} },
- edge: { id: 'preview-edge', source: 'node-1', target: 'preview-node' },
- };
-
mockReactFlowInstance.screenToFlowPosition = vi.fn().mockReturnValue({ x: 100, y: 100 });
- vi.mocked(mockCreatePreviewNode).mockReturnValue(mockPreview);
const { result } = renderHook(() => useAddNodeOnConnectEnd());
@@ -145,16 +127,16 @@ describe('useAddNodeOnConnectEnd', () => {
result.current(mockTouchEventWithTouches, connectionState);
- expect(mockCreatePreviewNode).toHaveBeenCalledWith(
- 'node-1',
- 'output',
- mockReactFlowInstance,
- expect.any(Object),
- undefined,
- 'source',
- undefined, // previewNodeSize (uses default)
- Position.Right, // handlePosition from connectionState.fromHandle.position
- [] // ignoredNodeTypes
+ expect(mockShowPreviewGraph).toHaveBeenCalledWith(
+ expect.objectContaining({
+ sourceNodeId: 'node-1',
+ sourceHandleId: 'output',
+ reactFlowInstance: mockReactFlowInstance,
+ position: expect.any(Object),
+ sourceHandleType: 'source',
+ handlePosition: Position.Right,
+ ignoredNodeTypes: [],
+ })
);
});
@@ -168,8 +150,7 @@ describe('useAddNodeOnConnectEnd', () => {
result.current({} as MouseEvent, connectionState);
- expect(mockCreatePreviewNode).not.toHaveBeenCalled();
- expect(mockApplyPreviewToReactFlow).not.toHaveBeenCalled();
+ expect(mockShowPreviewGraph).not.toHaveBeenCalled();
});
it('should not add preview if fromHandle is missing', () => {
@@ -182,8 +163,7 @@ describe('useAddNodeOnConnectEnd', () => {
result.current({} as MouseEvent, connectionState);
- expect(mockCreatePreviewNode).not.toHaveBeenCalled();
- expect(mockApplyPreviewToReactFlow).not.toHaveBeenCalled();
+ expect(mockShowPreviewGraph).not.toHaveBeenCalled();
});
it("should not add preview if 'to' is missing", () => {
@@ -196,8 +176,7 @@ describe('useAddNodeOnConnectEnd', () => {
result.current({} as MouseEvent, connectionState);
- expect(mockCreatePreviewNode).not.toHaveBeenCalled();
- expect(mockApplyPreviewToReactFlow).not.toHaveBeenCalled();
+ expect(mockShowPreviewGraph).not.toHaveBeenCalled();
});
it('should not add preview if connection already has a target handle', () => {
@@ -228,20 +207,17 @@ describe('useAddNodeOnConnectEnd', () => {
result.current({} as MouseEvent, connectionState);
- expect(mockCreatePreviewNode).not.toHaveBeenCalled();
- expect(mockApplyPreviewToReactFlow).not.toHaveBeenCalled();
+ expect(mockShowPreviewGraph).not.toHaveBeenCalled();
});
- it('should not apply preview if createPreviewNode returns null', () => {
+ it('should delegate preview creation to showPreviewGraph', () => {
mockReactFlowInstance.screenToFlowPosition = vi.fn().mockReturnValue({ x: 100, y: 100 });
- mockCreatePreviewNode.mockReturnValueOnce(null);
const { result } = renderHook(() => useAddNodeOnConnectEnd());
result.current(mockTouchEventWithChangedTouches, connectionStateBase);
- expect(mockCreatePreviewNode).toHaveBeenCalled();
- expect(mockApplyPreviewToReactFlow).not.toHaveBeenCalled();
+ expect(mockShowPreviewGraph).toHaveBeenCalledOnce();
});
it('should memoize callback with reactFlowInstance dependency', () => {
diff --git a/packages/apollo-react/src/canvas/hooks/useAddNodeOnConnectEnd.ts b/packages/apollo-react/src/canvas/hooks/useAddNodeOnConnectEnd.ts
index 2cc88aeec..d51f8b5d3 100644
--- a/packages/apollo-react/src/canvas/hooks/useAddNodeOnConnectEnd.ts
+++ b/packages/apollo-react/src/canvas/hooks/useAddNodeOnConnectEnd.ts
@@ -1,9 +1,18 @@
import { type OnConnectEnd, useReactFlow } from '@uipath/apollo-react/canvas/xyflow/react';
import { useCallback } from 'react';
-import { applyPreviewToReactFlow, createPreviewNode } from '../utils';
+import { showPreviewGraph } from '../utils';
const EMPTY_IGNORED_NODE_TYPES: string[] = [];
+function getClientPosition(event: MouseEvent | TouchEvent): { x: number; y: number } | null {
+ if ('clientX' in event) {
+ return { x: event.clientX, y: event.clientY };
+ }
+
+ const touch = event.changedTouches?.[0] ?? event.touches?.[0];
+ return touch ? { x: touch.clientX, y: touch.clientY } : null;
+}
+
/**
* Use this hook to get a callback that adds a preview node when a connection ends on an empty space.
* Uses React Flow context, so it is important that the component using this hook is a child of ReactFlowProvider.
@@ -22,47 +31,21 @@ export function useAddNodeOnConnectEnd(ignoredNodeTypes: string[] = EMPTY_IGNORE
) {
return;
}
- // Calculate the position in flow coordinates
- let clientX: number;
- let clientY: number;
- if ('clientX' in event) {
- clientX = event.clientX;
- clientY = event.clientY;
- } else {
- const touchEvent = event as TouchEvent;
- if (touchEvent.changedTouches?.[0]) {
- const touch = touchEvent.changedTouches[0];
- clientX = touch.clientX;
- clientY = touch.clientY;
- } else if (touchEvent.touches?.[0]) {
- const touch = touchEvent.touches[0];
- clientX = touch.clientX;
- clientY = touch.clientY;
- } else {
- return;
- }
- }
+ const clientPosition = getClientPosition(event);
+ if (!clientPosition) return;
- const flowDropPosition = reactFlowInstance.screenToFlowPosition({
- x: clientX,
- y: clientY,
- });
+ const flowDropPosition = reactFlowInstance.screenToFlowPosition(clientPosition);
- const preview = createPreviewNode(
- connectionState.fromNode.id,
- connectionState.fromHandle.id ?? 'output',
+ showPreviewGraph({
+ sourceNodeId: connectionState.fromNode.id,
+ sourceHandleId: connectionState.fromHandle.id ?? 'output',
reactFlowInstance,
- flowDropPosition,
- undefined,
- connectionState.fromHandle.type,
- undefined, // Use default preview node size
- connectionState.fromHandle.position,
- ignoredNodeTypes
- );
- if (preview) {
- applyPreviewToReactFlow(preview, reactFlowInstance);
- }
+ position: flowDropPosition,
+ sourceHandleType: connectionState.fromHandle.type,
+ handlePosition: connectionState.fromHandle.position,
+ ignoredNodeTypes,
+ });
},
[reactFlowInstance, ignoredNodeTypes]
);
diff --git a/packages/apollo-react/src/canvas/hooks/usePreviewNode.ts b/packages/apollo-react/src/canvas/hooks/usePreviewNode.ts
index eeb621a8a..f9fd279cb 100644
--- a/packages/apollo-react/src/canvas/hooks/usePreviewNode.ts
+++ b/packages/apollo-react/src/canvas/hooks/usePreviewNode.ts
@@ -10,6 +10,7 @@ import { shallow } from 'zustand/shallow';
import { PREVIEW_NODE_ID } from '../constants';
import { useOptionalNodeTypeRegistry } from '../core';
import type { HandleManifest, NodeManifest } from '../schema/node-definition';
+import { isPreviewEdge } from '../utils/createPreviewNode';
/**
* Information about an existing node connected to the preview node.
@@ -41,15 +42,13 @@ const previewNodeSelectedSelector = (state: ReactFlowState) => {
// Selector to track edges connected to preview node
// Returns minimal edge data to avoid unnecessary re-renders
const edgesConnectedToPreviewSelector = (state: ReactFlowState): Edge[] => {
- return state.edges
- .filter((edge) => edge.source === PREVIEW_NODE_ID || edge.target === PREVIEW_NODE_ID)
- .map((edge) => ({
- id: edge.id,
- source: edge.source,
- target: edge.target,
- sourceHandle: edge.sourceHandle,
- targetHandle: edge.targetHandle,
- }));
+ return state.edges.filter(isPreviewEdge).map((edge) => ({
+ id: edge.id,
+ source: edge.source,
+ target: edge.target,
+ sourceHandle: edge.sourceHandle,
+ targetHandle: edge.targetHandle,
+ }));
};
interface UsePreviewNodeResult {
diff --git a/packages/apollo-react/src/canvas/schema/node-definition/handle.ts b/packages/apollo-react/src/canvas/schema/node-definition/handle.ts
index 89736b63a..3ddaecd12 100644
--- a/packages/apollo-react/src/canvas/schema/node-definition/handle.ts
+++ b/packages/apollo-react/src/canvas/schema/node-definition/handle.ts
@@ -12,6 +12,7 @@ import { connectionConstraintSchema } from './constraints';
* Uses XYFlow Position enum values
*/
export const handlePositionSchema = z.enum(['left', 'top', 'right', 'bottom']);
+export const handleBoundarySchema = z.enum(['outer', 'inner']);
/**
* Handle type (source or target)
@@ -117,6 +118,13 @@ export const handleGroupManifestSchema = z.object({
/** Position on the node */
position: handlePositionSchema,
+ /**
+ * Optional boundary for container-style nodes.
+ * `outer` renders on the node shell; `inner` renders on an inner frame/wall.
+ * Defaults to `outer` when omitted.
+ */
+ boundary: handleBoundarySchema.optional(),
+
customPositionAndOffsets: handleConfigurationSpecificPositionSchema.optional(),
/** Array of handles at this position */
@@ -143,6 +151,7 @@ export const handleGroupOverrideSchema = z.object({
// Export inferred types
export type HandlePosition = z.infer;
+export type HandleBoundary = z.infer;
export type HandleType = z.infer;
export type HandleCategory = z.infer;
export type HandleManifest = z.infer;
diff --git a/packages/apollo-react/src/canvas/schema/node-definition/index.ts b/packages/apollo-react/src/canvas/schema/node-definition/index.ts
index 591005e5a..8a41b87dd 100644
--- a/packages/apollo-react/src/canvas/schema/node-definition/index.ts
+++ b/packages/apollo-react/src/canvas/schema/node-definition/index.ts
@@ -3,6 +3,7 @@ export { categoryManifestSchema } from './category-manifest';
export type { ConnectionConstraint, HandleTarget } from './constraints';
// Re-export types
export type {
+ HandleBoundary,
HandleCategory,
HandleGroupManifest,
HandleGroupOverride,
@@ -12,6 +13,7 @@ export type {
} from './handle';
// Re-export schemas
export {
+ handleBoundarySchema,
handleGroupManifestSchema,
handleGroupOverrideSchema,
handleManifestSchema,
@@ -28,6 +30,6 @@ export type {
export {
nodeDisplayManifestSchema,
nodeManifestSchema,
- nodeShapeSchema,
nodeRuntimeConstraintsManifestSchema,
+ nodeShapeSchema,
} from './node-manifest';
diff --git a/packages/apollo-react/src/canvas/schema/node-definition/node-manifest.ts b/packages/apollo-react/src/canvas/schema/node-definition/node-manifest.ts
index 0a5c4ca76..2bfd48de1 100644
--- a/packages/apollo-react/src/canvas/schema/node-definition/node-manifest.ts
+++ b/packages/apollo-react/src/canvas/schema/node-definition/node-manifest.ts
@@ -12,7 +12,7 @@ import { handleGroupManifestSchema } from './handle';
/**
* Node shape for display
*/
-export const nodeShapeSchema = z.enum(['circle', 'square', 'rectangle']);
+export const nodeShapeSchema = z.enum(['circle', 'square', 'rectangle', 'container']);
/**
* Debug configuration for a node
diff --git a/packages/apollo-react/src/canvas/storybook-utils/hooks/useCanvasStory.ts b/packages/apollo-react/src/canvas/storybook-utils/hooks/useCanvasStory.ts
index c69c0d56d..994d5950d 100644
--- a/packages/apollo-react/src/canvas/storybook-utils/hooks/useCanvasStory.ts
+++ b/packages/apollo-react/src/canvas/storybook-utils/hooks/useCanvasStory.ts
@@ -10,6 +10,7 @@ import { useCallback, useMemo } from 'react';
import { AddNodePreview } from '../../components';
import { BaseNode } from '../../components/BaseNode/BaseNode';
import { SequenceEdge } from '../../components/Edges';
+import { isContainerNodeManifest, LoopCanvasNode } from '../../components/LoopNode';
import { useNodeTypeRegistry } from '../../core';
/**
@@ -126,9 +127,9 @@ export function useCanvasStory(options: UseCanvasStoryOptions): UseCanvasStoryRe
);
const nodeTypes = useMemo(() => {
- const types = nodeTypeRegistry.getAllNodeTypes().reduce(
- (acc, nodeType) => {
- acc[nodeType] = nodeComponent;
+ const types = nodeTypeRegistry.getAllManifests().reduce(
+ (acc, manifest) => {
+ acc[manifest.nodeType] = isContainerNodeManifest(manifest) ? LoopCanvasNode : nodeComponent;
return acc;
},
{
@@ -188,9 +189,9 @@ export function useNodeTypesFromRegistry(nodeComponent: NodeTypes[string] = Base
const nodeTypeRegistry = useNodeTypeRegistry();
return useMemo(() => {
- return nodeTypeRegistry.getAllNodeTypes().reduce(
- (acc, nodeType) => {
- acc[nodeType] = nodeComponent;
+ return nodeTypeRegistry.getAllManifests().reduce(
+ (acc, manifest) => {
+ acc[manifest.nodeType] = isContainerNodeManifest(manifest) ? LoopCanvasNode : nodeComponent;
return acc;
},
{ default: nodeComponent } as NodeTypes
diff --git a/packages/apollo-react/src/canvas/storybook-utils/manifests/node-definitions.ts b/packages/apollo-react/src/canvas/storybook-utils/manifests/node-definitions.ts
index 31e391611..1e0df1656 100644
--- a/packages/apollo-react/src/canvas/storybook-utils/manifests/node-definitions.ts
+++ b/packages/apollo-react/src/canvas/storybook-utils/manifests/node-definitions.ts
@@ -229,6 +229,7 @@ export const allNodeManifests: NodeManifest[] = [
display: {
label: 'For Each',
icon: 'repeat',
+ shape: 'container',
},
handleConfiguration: [
{
@@ -240,18 +241,42 @@ export const allNodeManifests: NodeManifest[] = [
handles: [
{
id: 'success',
- label: 'Completed',
+ label: 'Success',
type: 'source',
handleType: 'output',
},
+ ],
+ },
+ {
+ position: 'left',
+ boundary: 'inner',
+ handles: [
{
- id: 'body',
- label: 'Body',
+ id: 'start',
+ label: 'Start',
type: 'source',
handleType: 'output',
},
],
},
+ {
+ position: 'right',
+ boundary: 'inner',
+ handles: [
+ {
+ id: 'continue',
+ label: 'Continue',
+ type: 'target',
+ handleType: 'input',
+ },
+ {
+ id: 'break',
+ label: 'Break',
+ type: 'target',
+ handleType: 'input',
+ },
+ ],
+ },
],
},
diff --git a/packages/apollo-react/src/canvas/styles/reactflow-reset.css b/packages/apollo-react/src/canvas/styles/reactflow-reset.css
index 2735b8666..0bfb8acdf 100644
--- a/packages/apollo-react/src/canvas/styles/reactflow-reset.css
+++ b/packages/apollo-react/src/canvas/styles/reactflow-reset.css
@@ -34,12 +34,13 @@
}
/**
- * Elevate hovered nodes so toolbars and handle buttons aren't clipped by
- * neighboring node wrappers. React Flow sets z-index inline, so `!important`
- * is required to override it.
+ * Elevate hovered leaf nodes so toolbars and handle buttons aren't clipped by
+ * neighboring node wrappers or connected edge SVGs. This intentionally clears
+ * React Flow's selected-node z-index bump (+1000) and connection-line z-index (1001).
+ * React Flow sets z-index inline, so `!important` is required to override it.
*/
-.react-flow__node:hover {
- z-index: 1000 !important;
+.react-flow__node:not(.parent):hover {
+ z-index: 1002 !important;
}
/**
diff --git a/packages/apollo-react/src/canvas/utils/NodeUtils.ts b/packages/apollo-react/src/canvas/utils/NodeUtils.ts
index a8f9b56cb..e20be04c3 100644
--- a/packages/apollo-react/src/canvas/utils/NodeUtils.ts
+++ b/packages/apollo-react/src/canvas/utils/NodeUtils.ts
@@ -3,10 +3,18 @@ import {
type InternalNode,
type Node,
Position,
+ type ReactFlowState,
type XYPosition,
} from '@uipath/apollo-react/canvas/xyflow/react';
import { DEFAULT_NODE_SIZE, GRID_SPACING, PREVIEW_NODE_ID } from '../constants';
+// Use `connection.inProgress` rather than `connectionClickStartHandle`.
+// `connectionClickStartHandle` is set by click-to-connect and only cleared when
+// the user clicks a second handle — clicking the pane does NOT clear it, so it
+// can get stuck and cause all handles across all nodes to stay visible.
+// `connection.inProgress` accurately reflects an active drag-to-connect gesture.
+export const selectIsConnecting = (state: ReactFlowState) => !!state.connection.inProgress;
+
/**
* Calculates the absolute position of a node, taking into account its parent nodes.
*
diff --git a/packages/apollo-react/src/canvas/utils/collapse.ts b/packages/apollo-react/src/canvas/utils/collapse.ts
index cd6af6bac..f5a590098 100644
--- a/packages/apollo-react/src/canvas/utils/collapse.ts
+++ b/packages/apollo-react/src/canvas/utils/collapse.ts
@@ -5,6 +5,7 @@
* These helpers ensure consistent behavior across the codebase.
*/
+import { DEFAULT_CONTAINER_HEIGHT, DEFAULT_CONTAINER_WIDTH } from '../constants';
import type { NodeShape } from '../schema/node-definition';
/**
@@ -51,6 +52,13 @@ export const getCollapsedSize = (): { width: number; height: number } => {
* when expanding a collapsed node.
*/
export const getExpandedSize = (shape?: NodeShape): { width: number; height: number } => {
+ if (shape === 'container') {
+ return {
+ width: DEFAULT_CONTAINER_WIDTH,
+ height: DEFAULT_CONTAINER_HEIGHT,
+ };
+ }
+
return {
width: shape === 'rectangle' ? EXPANDED_RECTANGLE_WIDTH : COLLAPSED_NODE_SIZE,
height: COLLAPSED_NODE_SIZE,
diff --git a/packages/apollo-react/src/canvas/utils/createPreviewGraph.test.ts b/packages/apollo-react/src/canvas/utils/createPreviewGraph.test.ts
new file mode 100644
index 000000000..b8e02ba00
--- /dev/null
+++ b/packages/apollo-react/src/canvas/utils/createPreviewGraph.test.ts
@@ -0,0 +1,196 @@
+import type { Edge, Node, ReactFlowInstance } from '@uipath/apollo-react/canvas/xyflow/react';
+import { Position } from '@uipath/apollo-react/canvas/xyflow/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { DEFAULT_SOURCE_HANDLE_ID, PREVIEW_EDGE_ID, PREVIEW_NODE_ID } from '../constants';
+import { applyPreviewGraphToReactFlow, createPreviewGraph } from './createPreviewGraph';
+
+function createReactFlowInstance({
+ nodes,
+ edges = [],
+}: {
+ nodes: Node[];
+ edges?: Edge[];
+}): ReactFlowInstance {
+ let currentNodes = nodes;
+ let currentEdges = edges;
+
+ return {
+ getNode: (id: string) => currentNodes.find((node) => node.id === id),
+ getInternalNode: () => undefined,
+ getNodes: () => currentNodes,
+ getEdges: () => currentEdges,
+ setNodes: (updater: Node[] | ((nodes: Node[]) => Node[])) => {
+ currentNodes = typeof updater === 'function' ? updater(currentNodes) : updater;
+ },
+ setEdges: (updater: Edge[] | ((edges: Edge[]) => Edge[])) => {
+ currentEdges = typeof updater === 'function' ? updater(currentEdges) : updater;
+ },
+ } as unknown as ReactFlowInstance;
+}
+
+describe('createPreviewGraph', () => {
+ const sourceNode: Node = {
+ id: 'source-node',
+ type: 'source',
+ position: { x: 0, y: 0 },
+ data: {},
+ };
+ const targetNode: Node = {
+ id: 'target-node',
+ type: 'target',
+ position: { x: 400, y: 0 },
+ data: {},
+ };
+
+ beforeEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('creates a trailing preview edge with the requested id and handles', () => {
+ const reactFlowInstance = createReactFlowInstance({
+ nodes: [sourceNode, targetNode],
+ });
+
+ const preview = createPreviewGraph({
+ sourceNodeId: sourceNode.id,
+ sourceHandleId: 'source-output',
+ reactFlowInstance,
+ position: { x: 200, y: 100 },
+ handlePosition: Position.Right,
+ targetNodeId: targetNode.id,
+ targetHandleId: 'target-input',
+ trailingEdgeId: 'preview-to-target',
+ });
+
+ expect(preview?.edges).toEqual([
+ expect.objectContaining({
+ id: PREVIEW_EDGE_ID,
+ source: sourceNode.id,
+ sourceHandle: 'source-output',
+ target: PREVIEW_NODE_ID,
+ targetHandle: 'input',
+ }),
+ expect.objectContaining({
+ id: 'preview-to-target',
+ source: PREVIEW_NODE_ID,
+ sourceHandle: DEFAULT_SOURCE_HANDLE_ID,
+ target: targetNode.id,
+ targetHandle: 'target-input',
+ }),
+ ]);
+ });
+
+ it('converts preview positions from absolute coordinates to container-relative coordinates', () => {
+ const containerNode: Node = {
+ id: 'container-node',
+ type: 'loop',
+ position: { x: 100, y: 50 },
+ data: {},
+ };
+ const reactFlowInstance = createReactFlowInstance({
+ nodes: [containerNode, sourceNode],
+ });
+
+ const preview = createPreviewGraph({
+ sourceNodeId: sourceNode.id,
+ sourceHandleId: 'source-output',
+ reactFlowInstance,
+ position: { x: 240, y: 170 },
+ positionMode: 'center',
+ previewNodeSize: { width: 80, height: 40 },
+ handlePosition: Position.Right,
+ containerId: containerNode.id,
+ });
+
+ expect(preview?.node).toMatchObject({
+ id: PREVIEW_NODE_ID,
+ parentId: containerNode.id,
+ extent: 'parent',
+ position: { x: 100, y: 100 },
+ });
+ });
+
+ it('infers the source parent as the preview container for child node button handles', () => {
+ const containerNode: Node = {
+ id: 'loop-node',
+ type: 'loop',
+ position: { x: 100, y: 50 },
+ data: {},
+ };
+ const childNode: Node = {
+ id: 'child-node',
+ type: 'activity',
+ parentId: containerNode.id,
+ position: { x: 80, y: 120 },
+ measured: { width: 120, height: 64 },
+ data: {},
+ };
+ const reactFlowInstance = createReactFlowInstance({
+ nodes: [containerNode, childNode],
+ });
+
+ const preview = createPreviewGraph({
+ sourceNodeId: childNode.id,
+ sourceHandleId: 'output',
+ reactFlowInstance,
+ handlePosition: Position.Right,
+ });
+
+ expect(preview?.node).toMatchObject({
+ id: PREVIEW_NODE_ID,
+ parentId: containerNode.id,
+ extent: 'parent',
+ });
+ expect(preview?.node.position.x).toBeGreaterThan(childNode.position.x);
+ });
+
+ it('filters removed edges only when the preview graph is applied', () => {
+ vi.useFakeTimers();
+
+ const replacedEdge: Edge = {
+ id: 'edge-to-replace',
+ source: sourceNode.id,
+ sourceHandle: 'source-output',
+ target: targetNode.id,
+ targetHandle: 'target-input',
+ };
+ const retainedEdge: Edge = {
+ id: 'edge-to-keep',
+ source: 'other-source',
+ target: 'other-target',
+ };
+ const reactFlowInstance = createReactFlowInstance({
+ nodes: [sourceNode, targetNode],
+ edges: [replacedEdge, retainedEdge],
+ });
+
+ const preview = createPreviewGraph({
+ sourceNodeId: sourceNode.id,
+ sourceHandleId: 'source-output',
+ reactFlowInstance,
+ position: { x: 200, y: 100 },
+ handlePosition: Position.Right,
+ targetNodeId: targetNode.id,
+ targetHandleId: 'target-input',
+ removedEdgeIds: [replacedEdge.id],
+ });
+
+ expect(reactFlowInstance.getEdges()).toEqual([replacedEdge, retainedEdge]);
+
+ applyPreviewGraphToReactFlow(preview!, reactFlowInstance);
+ vi.runOnlyPendingTimers();
+
+ expect(reactFlowInstance.getEdges()).toEqual([
+ retainedEdge,
+ expect.objectContaining({
+ id: PREVIEW_EDGE_ID,
+ source: sourceNode.id,
+ target: PREVIEW_NODE_ID,
+ }),
+ expect.objectContaining({
+ source: PREVIEW_NODE_ID,
+ target: targetNode.id,
+ }),
+ ]);
+ });
+});
diff --git a/packages/apollo-react/src/canvas/utils/createPreviewGraph.ts b/packages/apollo-react/src/canvas/utils/createPreviewGraph.ts
new file mode 100644
index 000000000..dcee0a269
--- /dev/null
+++ b/packages/apollo-react/src/canvas/utils/createPreviewGraph.ts
@@ -0,0 +1,199 @@
+import type {
+ Edge,
+ Node,
+ Position,
+ ReactFlowInstance,
+} from '@uipath/apollo-react/canvas/xyflow/react';
+import { DEFAULT_SOURCE_HANDLE_ID, PREVIEW_NODE_ID } from '../constants';
+import {
+ createPreviewNode,
+ isPreviewEdge,
+ PREVIEW_EDGE_STYLE,
+ type PreviewNodePositionMode,
+} from './createPreviewNode';
+import { getAbsolutePosition } from './NodeUtils';
+
+export interface PreviewGraph {
+ node: Node;
+ edges: Edge[];
+ removedEdgeIds?: string[];
+}
+
+export interface CreatePreviewGraphOptions {
+ sourceNodeId: string;
+ sourceHandleId: string;
+ reactFlowInstance: ReactFlowInstance;
+ position?: { x: number; y: number };
+ data?: Record;
+ sourceHandleType?: 'source' | 'target';
+ previewNodeSize?: { width: number; height: number };
+ handlePosition?: Position;
+ ignoredNodeTypes?: string[];
+ positionMode?: PreviewNodePositionMode;
+ targetNodeId?: string;
+ targetHandleId?: string | null;
+ containerId?: string;
+ removedEdgeIds?: string[];
+ trailingEdgeId?: string;
+ trailingEdgeStyle?: Edge['style'];
+}
+
+function inferPreviewContainerId(sourceNode: Node, targetNode?: Node): string | undefined {
+ if (!targetNode) {
+ return sourceNode.parentId;
+ }
+
+ if (sourceNode.parentId === targetNode.parentId) {
+ return sourceNode.parentId;
+ }
+
+ if (targetNode.parentId === sourceNode.id) {
+ return sourceNode.id;
+ }
+
+ if (sourceNode.parentId === targetNode.id) {
+ return targetNode.id;
+ }
+
+ return undefined;
+}
+
+export function reparentPreviewNodeToContainer(
+ previewNode: Node,
+ containerId: string,
+ reactFlowInstance: ReactFlowInstance
+): Node | null {
+ const containerNode = reactFlowInstance.getNode(containerId);
+ if (!containerNode) {
+ return null;
+ }
+
+ const containerAbsolutePosition = getAbsolutePosition(
+ containerNode,
+ reactFlowInstance.getNodes()
+ );
+
+ return {
+ ...previewNode,
+ position: {
+ x: previewNode.position.x - containerAbsolutePosition.x,
+ y: previewNode.position.y - containerAbsolutePosition.y,
+ },
+ parentId: containerId,
+ extent: 'parent',
+ };
+}
+
+function createPreviewNodeForGraph({
+ sourceNodeId,
+ sourceHandleId,
+ reactFlowInstance,
+ position,
+ data,
+ sourceHandleType,
+ previewNodeSize,
+ handlePosition,
+ ignoredNodeTypes,
+ positionMode,
+}: CreatePreviewGraphOptions) {
+ return createPreviewNode(
+ sourceNodeId,
+ sourceHandleId,
+ reactFlowInstance,
+ position,
+ data,
+ sourceHandleType,
+ previewNodeSize,
+ handlePosition,
+ ignoredNodeTypes,
+ positionMode
+ );
+}
+
+function createTrailingPreviewEdge({
+ targetNodeId,
+ targetHandleId,
+ trailingEdgeId,
+ trailingEdgeStyle = PREVIEW_EDGE_STYLE,
+}: Pick<
+ CreatePreviewGraphOptions,
+ 'targetNodeId' | 'targetHandleId' | 'trailingEdgeId' | 'trailingEdgeStyle'
+>): Edge | null {
+ if (!targetNodeId) return null;
+
+ return {
+ id: trailingEdgeId ?? `${PREVIEW_NODE_ID}-${targetNodeId}`,
+ source: PREVIEW_NODE_ID,
+ sourceHandle: DEFAULT_SOURCE_HANDLE_ID,
+ target: targetNodeId,
+ targetHandle: targetHandleId,
+ type: 'default',
+ style: trailingEdgeStyle,
+ };
+}
+
+export function createPreviewGraph(options: CreatePreviewGraphOptions): PreviewGraph | null {
+ const { reactFlowInstance, targetNodeId, containerId, removedEdgeIds, sourceNodeId } = options;
+
+ const preview = createPreviewNodeForGraph(options);
+ if (!preview) return null;
+
+ const sourceNode = reactFlowInstance.getNode(sourceNodeId);
+ if (!sourceNode) return null;
+
+ const targetNode = targetNodeId ? reactFlowInstance.getNode(targetNodeId) : undefined;
+ const resolvedContainerId = containerId ?? inferPreviewContainerId(sourceNode, targetNode);
+ const finalPreviewNode = resolvedContainerId
+ ? reparentPreviewNodeToContainer(preview.node, resolvedContainerId, reactFlowInstance)
+ : preview.node;
+
+ if (!finalPreviewNode) return null;
+
+ const trailingEdge = createTrailingPreviewEdge(options);
+ const edges = trailingEdge ? [preview.edge, trailingEdge] : [preview.edge];
+
+ return {
+ node: finalPreviewNode,
+ edges,
+ removedEdgeIds,
+ };
+}
+
+/**
+ * Creates and shows a preview graph in React Flow.
+ * Returns the created preview graph when successful so callers can still
+ * inspect the result if needed.
+ */
+export function showPreviewGraph(options: CreatePreviewGraphOptions): PreviewGraph | null {
+ const preview = createPreviewGraph(options);
+ if (preview) {
+ applyPreviewGraphToReactFlow(preview, options.reactFlowInstance);
+ }
+ return preview;
+}
+
+/**
+ * Applies a preview graph to React Flow.
+ * This supports both the classic single-edge preview and multi-edge previews
+ * such as container/loop insertion flows.
+ */
+export function applyPreviewGraphToReactFlow(
+ preview: PreviewGraph,
+ reactFlowInstance: ReactFlowInstance
+): void {
+ const removedEdgeIds = new Set(preview.removedEdgeIds ?? []);
+
+ setTimeout(() => {
+ reactFlowInstance.setNodes((nodes) => [
+ ...nodes
+ .filter((node) => node.id !== PREVIEW_NODE_ID)
+ .map((node) => ({ ...node, selected: false })),
+ preview.node,
+ ]);
+
+ reactFlowInstance.setEdges((edges) => [
+ ...edges.filter((edge) => !isPreviewEdge(edge) && !removedEdgeIds.has(edge.id)),
+ ...preview.edges,
+ ]);
+ }, 0);
+}
diff --git a/packages/apollo-react/src/canvas/utils/createPreviewNode.ts b/packages/apollo-react/src/canvas/utils/createPreviewNode.ts
index 30eeb0f1c..3e58c26cc 100644
--- a/packages/apollo-react/src/canvas/utils/createPreviewNode.ts
+++ b/packages/apollo-react/src/canvas/utils/createPreviewNode.ts
@@ -12,11 +12,28 @@ import {
resolveHandleContext,
} from './NodeUtils';
+export type PreviewNodePositionMode = 'drop' | 'center';
+
+export const PREVIEW_EDGE_STYLE: Edge['style'] = {
+ strokeDasharray: '5,5',
+ opacity: 0.8,
+ stroke: 'var(--canvas-selection-indicator)',
+ strokeWidth: 2,
+};
+
+export function isPreviewEdge(edge: { id?: string; source?: string; target?: string }): boolean {
+ return (
+ edge.id === PREVIEW_EDGE_ID ||
+ edge.source === PREVIEW_NODE_ID ||
+ edge.target === PREVIEW_NODE_ID
+ );
+}
+
/**
* Returns the opposite position for a given handle position.
* Used when dragging from a target handle where the preview should appear on the opposite side.
*/
-function getOppositePosition(position: Position): Position {
+export function getOppositePosition(position: Position): Position {
switch (position) {
case Position.Left:
return Position.Right;
@@ -74,6 +91,16 @@ function calculatePositionFromDrop(
}
}
+function calculateCenteredPosition(
+ centerPosition: { x: number; y: number },
+ previewNodeSize: { width: number; height: number }
+): { x: number; y: number } {
+ return {
+ x: centerPosition.x - previewNodeSize.width / 2,
+ y: centerPosition.y - previewNodeSize.height / 2,
+ };
+}
+
/**
* Returns the spread offset for a handle within a multi-handle group.
* Left/top half shifts by -size, right/bottom half by +size, middle stays at 0.
@@ -201,14 +228,15 @@ export function createPreviewNode(
sourceHandleId: string,
reactFlowInstance: ReactFlowInstance,
position?: { x: number; y: number },
- data?: Record,
+ data?: Record,
sourceHandleType: 'source' | 'target' = 'source',
previewNodeSize: { width: number; height: number } = {
width: DEFAULT_NODE_SIZE,
height: DEFAULT_NODE_SIZE,
},
handlePosition: Position = Position.Right,
- ignoredNodeTypes: string[] = []
+ ignoredNodeTypes: string[] = [],
+ positionMode: PreviewNodePositionMode = 'drop'
): { node: Node; edge: Edge } | null {
const sourceNode = reactFlowInstance.getNode(sourceNodeId);
if (!sourceNode) {
@@ -230,7 +258,9 @@ export function createPreviewNode(
: undefined;
const nodePosition = position
- ? calculatePositionFromDrop(position, handlePosition, previewNodeSize)
+ ? positionMode === 'center'
+ ? calculateCenteredPosition(position, previewNodeSize)
+ : calculatePositionFromDrop(position, handlePosition, previewNodeSize)
: calculateAutoPosition(
sourceNode,
handlePosition,
@@ -291,12 +321,7 @@ export function createPreviewNode(
id: PREVIEW_EDGE_ID,
...previewSourceAndTargetData,
type: 'default',
- style: {
- strokeDasharray: '5,5',
- opacity: 0.8,
- stroke: 'var(--canvas-selection-indicator)',
- strokeWidth: 2,
- },
+ style: PREVIEW_EDGE_STYLE,
};
return { node: previewNode, edge: previewEdge };
@@ -319,7 +344,7 @@ export function applyPreviewToReactFlow(
]);
reactFlowInstance.setEdges((edges) => [
- ...edges.filter((e) => e.id !== PREVIEW_EDGE_ID),
+ ...edges.filter((edge) => !isPreviewEdge(edge)),
preview.edge,
]);
}, 0);
@@ -330,5 +355,5 @@ export function applyPreviewToReactFlow(
*/
export function removePreviewFromReactFlow(reactFlowInstance: ReactFlowInstance): void {
reactFlowInstance.setNodes((nodes) => nodes.filter((n) => n.id !== PREVIEW_NODE_ID));
- reactFlowInstance.setEdges((edges) => edges.filter((e) => e.id !== PREVIEW_EDGE_ID));
+ reactFlowInstance.setEdges((edges) => edges.filter((edge) => !isPreviewEdge(edge)));
}
diff --git a/packages/apollo-react/src/canvas/utils/index.ts b/packages/apollo-react/src/canvas/utils/index.ts
index 2f8841a28..0234acd42 100644
--- a/packages/apollo-react/src/canvas/utils/index.ts
+++ b/packages/apollo-react/src/canvas/utils/index.ts
@@ -2,10 +2,11 @@ export * from './ArrayUtil';
export * from './auto-layout';
export * from './CanvasEventBus';
export * from './CssUtil';
-export * from './collapse';
export * from './coded-agents/d3-layout';
export * from './coded-agents/mermaid-parser';
+export * from './collapse';
export * from './constraint-validator';
+export * from './createPreviewGraph';
export * from './createPreviewNode';
export * from './export-canvas';
export * from './GroupModificationUtils';