diff --git a/packages/apollo-react/src/canvas/components/AddNodePanel/AddNodeManager.test.tsx b/packages/apollo-react/src/canvas/components/AddNodePanel/AddNodeManager.test.tsx index 7c4399c23..d28d5e3ba 100644 --- a/packages/apollo-react/src/canvas/components/AddNodePanel/AddNodeManager.test.tsx +++ b/packages/apollo-react/src/canvas/components/AddNodePanel/AddNodeManager.test.tsx @@ -121,6 +121,31 @@ describe('AddNodeManager', () => { }); }); + it('preserves preview parent scope when replacing a loop child preview', () => { + const parentedPreviewNode: Node = { + ...previewNode, + parentId: 'loop-1', + extent: 'parent', + }; + mockNodes = [existingNode, parentedPreviewNode]; + mockPreviewNodeReturn.mockReturnValue({ + previewNode: parentedPreviewNode, + previewNodeConnectionInfo: connectionInfo, + }); + + render(); + + screen.getByTestId('select-node').click(); + + const addedNode = mockNodes.find((n) => n.id === 'test-node-1234567890'); + expect(addedNode).toMatchObject({ + id: 'test-node-1234567890', + parentId: 'loop-1', + extent: 'parent', + position: parentedPreviewNode.position, + }); + }); + it('applies onBeforeNodeAdded transforms when node is added as source', () => { const onBeforeNodeAdded = vi.fn((node: Node, edges: Edge[]) => ({ newNode: { diff --git a/packages/apollo-react/src/canvas/components/AddNodePanel/AddNodeManager.tsx b/packages/apollo-react/src/canvas/components/AddNodePanel/AddNodeManager.tsx index 1d4684e38..86ad2eac3 100644 --- a/packages/apollo-react/src/canvas/components/AddNodePanel/AddNodeManager.tsx +++ b/packages/apollo-react/src/canvas/components/AddNodePanel/AddNodeManager.tsx @@ -133,6 +133,9 @@ export const AddNodeManager: React.FC = ({ const nodeData = currentPreviewNode.data?.useSmartHandles ? { ...baseNodeData, useSmartHandles: true } : baseNodeData; + const previewNodeScope = currentPreviewNode.parentId + ? { parentId: currentPreviewNode.parentId, extent: currentPreviewNode.extent } + : {}; // Create new node at preview position const newNode: Node = { @@ -141,6 +144,7 @@ export const AddNodeManager: React.FC = ({ position: currentPreviewNode.position, selected: true, data: nodeData, + ...previewNodeScope, }; // Get the manifest for the new node type to find its default handles const newNodeManifest = registry?.getManifest(nodeItem.data.type); diff --git a/packages/apollo-react/src/canvas/components/AddNodePanel/createAddNodePreview.ts b/packages/apollo-react/src/canvas/components/AddNodePanel/createAddNodePreview.ts index e46f3988e..0beecd192 100644 --- a/packages/apollo-react/src/canvas/components/AddNodePanel/createAddNodePreview.ts +++ b/packages/apollo-react/src/canvas/components/AddNodePanel/createAddNodePreview.ts @@ -1,5 +1,5 @@ import { Position, type ReactFlowInstance } from '@uipath/apollo-react/canvas/xyflow/react'; -import { applyPreviewToReactFlow, createPreviewNode } from '../../utils/createPreviewNode'; +import { showPreviewGraph } from '../../utils/createPreviewGraph'; /** * Creates a preview node and edge when a button handle is clicked. @@ -19,20 +19,12 @@ export function createAddNodePreview( sourceHandleType: 'source' | 'target' = 'source', ignoredNodeTypes: string[] = [] ): void { - // Use the unified preview creation utility - const preview = createPreviewNode( + showPreviewGraph({ sourceNodeId, sourceHandleId, reactFlowInstance, - undefined, // No drop position - use auto-placement - undefined, // No custom data sourceHandleType, - undefined, // Use default preview node size handlePosition, - ignoredNodeTypes - ); - - if (preview) { - applyPreviewToReactFlow(preview, reactFlowInstance); - } + ignoredNodeTypes, + }); } diff --git a/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.tsx b/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.tsx index 65a9da027..75d7bfa4d 100644 --- a/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.tsx +++ b/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.tsx @@ -1,4 +1,4 @@ -import type { Node, NodeProps, ReactFlowState } from '@uipath/apollo-react/canvas/xyflow/react'; +import type { Node, NodeProps } from '@uipath/apollo-react/canvas/xyflow/react'; import { Position, useReactFlow, @@ -27,6 +27,7 @@ import type { HandleGroupManifest } from '../../schema/node-definition'; import { resolveAdornments } from '../../utils/adornment-resolver'; import { getIcon } from '../../utils/icon-registry'; import { resolveDisplay, resolveHandles } from '../../utils/manifest-resolver'; +import { selectIsConnecting } from '../../utils/NodeUtils'; import { resolveToolbar } from '../../utils/toolbar-resolver'; import { useBaseCanvasMode } from '../BaseCanvas/BaseCanvasModeProvider'; import { useCanvasTheme } from '../BaseCanvas/CanvasThemeContext'; @@ -49,13 +50,6 @@ import { BaseInnerShape } from './BaseNodeInnerShape'; import { MissingManifestNode } from './BaseNodeMissingManifest'; import { NodeLabel } from './NodeLabel'; -// 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. -const selectIsConnecting = (state: ReactFlowState) => !!state.connection.inProgress; - const getContainerWidth = (shape: NodeShape | undefined, width: number | undefined) => { const defaultWidth = shape === 'rectangle' ? DEFAULT_RECTANGLE_NODE_WIDTH : DEFAULT_NODE_SIZE; if (width && width !== DEFAULT_NODE_SIZE && width !== DEFAULT_RECTANGLE_NODE_WIDTH) { @@ -87,7 +81,7 @@ const getContainerHeight = ( }; const BaseNodeComponent = (props: NodeProps>) => { - const { type, data, selected, id, dragging, width, height } = props; + const { type, data, selected, id, dragging, width, height, parentId } = props; // Read runtime configuration from context (provided by parent node components) const { @@ -260,6 +254,7 @@ const BaseNodeComponent = (props: NodeProps>) => { // Compute height: max of base height (user-specified or measured) and handle minimum. // baseHeightRef is updated above from external height changes; handle inflation // is computed from the current handleConfigurations. + // biome-ignore lint/correctness/useExhaustiveDependencies: height updates baseHeightRef above and intentionally retriggers this memo. const computedHeight = useMemo(() => { const leftHandles = handleConfigurations .filter((config) => config.position === Position.Left && config.visible !== false) @@ -280,12 +275,11 @@ const BaseNodeComponent = (props: NodeProps>) => { // Each handle gets a 2-grid-space lane (32px), plus 2-grid-space padding at top + bottom of node. const minNodeHeight = (leftRightHandles * 2 + 2) * GRID_SPACING; return Math.max(baseHeightRef.current, minNodeHeight); - // eslint-disable-next-line react-hooks/exhaustive-deps -- height is not read directly but triggers recalculation after baseHeightRef is updated above }, [handleConfigurations, height]); + // biome-ignore lint/correctness/useExhaustiveDependencies: handle configuration changes require React Flow handle recalculation. useEffect(() => { updateNodeInternals(id); - // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional when handle configurations change so it recalculates edge positions }, [handleConfigurations, id, updateNodeInternals]); // Sync computed height to node when it differs from React Flow's current value @@ -493,6 +487,7 @@ const BaseNodeComponent = (props: NodeProps>) => { nodeWidth: width, nodeHeight: height, shouldShowAddButtonFn, + portalActions: !!parentId, }); // Generate SmartHandle elements from handle configurations (opt-in) diff --git a/packages/apollo-react/src/canvas/components/ButtonHandle/ButtonHandle.tsx b/packages/apollo-react/src/canvas/components/ButtonHandle/ButtonHandle.tsx index 13ed9e8c0..9fa295a6d 100644 --- a/packages/apollo-react/src/canvas/components/ButtonHandle/ButtonHandle.tsx +++ b/packages/apollo-react/src/canvas/components/ButtonHandle/ButtonHandle.tsx @@ -3,16 +3,21 @@ import { memo, useCallback, useMemo, useRef, useState } from 'react'; import type { HandleConfigurationSpecificPosition } from '../../schema/node-definition/handle'; import { canvasEventBus } from '../../utils/CanvasEventBus'; import { cx } from '../../utils/CssUtil'; +import { + getHandleActionPortal, + getInwardHandleLayout, + type InwardHandleLayout, +} from './ButtonHandleLayoutUtils'; import { calculateGridAlignedHandlePositions, pixelToPercent } from './ButtonHandleStyleUtils'; import { HandleButton, HandleHoverBridge } from './HandleButton'; import { HandleLabel } from './HandleLabel'; -import { HandleNotch } from './HandleNotch'; +import { HandleNotch, type HandleType } from './HandleNotch'; import { useButtonHandleSizeAndPosition } from './useButtonHandleSizeAndPosition'; export interface HandleActionEvent { handleId: string; nodeId: string; - handleType: 'artifact' | 'input' | 'output'; + handleType: HandleType; position: Position; originalEvent: React.MouseEvent; } @@ -22,7 +27,10 @@ type ButtonHandleProps = { nodeId: string; type: 'source' | 'target'; position: Position; - handleType: 'artifact' | 'input' | 'output'; + // Defaults to `position`. Loop/container handles can render on one side while + // React Flow anchors the edge on another side. + connectionPosition?: Position; + handleType: HandleType; label?: string; labelIcon?: React.ReactNode; labelBackgroundColor?: string; @@ -36,6 +44,7 @@ type ButtonHandleProps = { customPositionAndOffsets?: HandleConfigurationSpecificPosition; nodeWidth?: number; nodeHeight?: number; + portalAction?: boolean; }; const ButtonHandleBase = ({ @@ -43,6 +52,7 @@ const ButtonHandleBase = ({ nodeId, type, position, + connectionPosition = position, handleType, label, labelIcon, @@ -57,6 +67,7 @@ const ButtonHandleBase = ({ customPositionAndOffsets, nodeWidth, nodeHeight, + portalAction = false, }: ButtonHandleProps) => { const handleRef = useRef(null); const [isHovered, setIsHovered] = useState(false); @@ -87,7 +98,7 @@ const ButtonHandleBase = ({ handleId: id, nodeId, handleType, - position, + position: connectionPosition, originalEvent: event, }; @@ -99,15 +110,16 @@ const ButtonHandleBase = ({ handleId: id, nodeId, handleType, - position, + position: connectionPosition, // timestamp: Date.now(), // Optional - uncomment if you need timing info }); }, - [id, nodeId, handleType, position, onAction] + [connectionPosition, id, nodeId, handleType, onAction] ); const markAsHovered = useCallback(() => setIsHovered(true), []); const unmarkAsHovered = useCallback(() => setIsHovered(false), []); + const showActionButton = !!onAction && type === 'source'; const { width: handleWidth, @@ -124,6 +136,78 @@ const ButtonHandleBase = ({ customPositionAndOffsets, }); + if (connectionPosition !== position) { + const layout = getInwardHandleLayout(position, handleType); + + return ( +
+ + + {showActionButton ? ( + + ) : null} +
+ ); + } + + const portal = + showActionButton && portalAction && !customPositionAndOffsets + ? getHandleActionPortal({ + nodeId, + position, + positionPercent, + total, + nodeWidth, + nodeHeight, + }) + : undefined; + return ( - {onAction && type === 'source' ? ( + {showActionButton ? ( ) : ( label && ( @@ -183,11 +268,64 @@ const ButtonHandleBase = ({ export const ButtonHandle = memo(ButtonHandleBase); +function InwardHandleContent({ + handleType, + isVertical, + selected, + hovered, + showNotch, + label, + labelIcon, + labelBackgroundColor, + layout, +}: { + handleType: HandleType; + isVertical: boolean; + selected: boolean; + hovered: boolean; + showNotch?: boolean; + label?: string; + labelIcon?: React.ReactNode; + labelBackgroundColor?: string; + layout: InwardHandleLayout; +}) { + const labelElement = label ? ( +
+ {labelIcon} + {label} +
+ ) : null; + const notchElement = ( + + + + ); + + return ( +
+ {labelElement} + {notchElement} +
+ ); +} + export interface ButtonHandleConfig { /** Is of type string but `ButtonHandleId` should be used for reserved ids */ id: string; type: 'source' | 'target'; - handleType: 'artifact' | 'input' | 'output'; + handleType: HandleType; label?: string; labelIcon?: React.ReactNode; showButton?: boolean; @@ -204,6 +342,7 @@ const ButtonHandlesBase = ({ nodeId, handles, position, + connectionPosition = position, selected = false, hovered = false, visible = true, @@ -214,10 +353,12 @@ const ButtonHandlesBase = ({ showAddButton && (selected || hovered), nodeWidth, nodeHeight, + portalActions = false, }: { nodeId: string; handles: ButtonHandleConfig[]; position: Position; + connectionPosition?: Position; selected?: boolean; hovered?: boolean; visible?: boolean; @@ -226,6 +367,8 @@ const ButtonHandlesBase = ({ customPositionAndOffsets?: HandleConfigurationSpecificPosition; nodeWidth?: number; nodeHeight?: number; + /** Render source handle affordances (button and label) in the node overlay layer. */ + portalActions?: boolean; /** * Allows for consumers to control the predicate for showing the add button from the props that's passed in @@ -252,35 +395,44 @@ const ButtonHandlesBase = ({ // group-level visibility (hover/selection state) is handled via opacity. const visibleHandles = handles.filter((h) => h.visible ?? true); - // Show the hover bridge when any source handle in this group has an onAction callback + // Show the hover bridge when any source handle in this group has an onAction callback. const hasSourceButtons = visibleHandles.some((h) => h.type === 'source' && h.onAction); return ( <> - - {visibleHandles.map((handle, index) => ( - - ))} + + {visibleHandles.map((handle, index) => { + const handleVisible = handle.showHandle ?? visible; + + return ( + + ); + })} ); }; diff --git a/packages/apollo-react/src/canvas/components/ButtonHandle/ButtonHandleLayoutUtils.ts b/packages/apollo-react/src/canvas/components/ButtonHandle/ButtonHandleLayoutUtils.ts new file mode 100644 index 000000000..4c7ba39a7 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/ButtonHandle/ButtonHandleLayoutUtils.ts @@ -0,0 +1,150 @@ +import { Position } from '@uipath/apollo-react/canvas/xyflow/react'; +import type { CSSProperties } from 'react'; +import { GRID_SPACING } from '../../constants'; +import { HANDLE_CROSS_AXIS_SIZE_PX, HANDLE_EDGE_COVERAGE_RATIO } from './ButtonHandleStyleUtils'; +import type { HandleButtonPortal } from './HandleButton'; +import type { HandleType } from './HandleNotch'; + +const INWARD_HANDLE_ANCHOR_SIZE_PX = GRID_SPACING; +const INWARD_HANDLE_ANCHOR_RADIUS_PX = INWARD_HANDLE_ANCHOR_SIZE_PX / 2; +const INWARD_NOTCH_OVERLAP_PX = { + artifact: 5, + input: 4, + output: 6, +} as const; + +export interface HandleActionPortalOptions { + nodeId: string; + position: Position; + positionPercent: number; + total: number; + nodeWidth?: number; + nodeHeight?: number; +} + +export type InwardHandleLayout = { + rootTransform: string; + contentDirectionClassName: string; + notchStyle: CSSProperties; + anchorStyle: CSSProperties; +}; + +export function getHandleActionPortal({ + nodeId, + position, + positionPercent, + total, + nodeWidth, + nodeHeight, +}: HandleActionPortalOptions): HandleButtonPortal | undefined { + if (!nodeWidth || !nodeHeight) { + return undefined; + } + + const edgeCoverageRatio = HANDLE_EDGE_COVERAGE_RATIO / total; + const horizontalWidth = nodeWidth * edgeCoverageRatio; + const verticalHeight = nodeHeight * edgeCoverageRatio; + const x = nodeWidth * (positionPercent / 100); + const y = nodeHeight * (positionPercent / 100); + + switch (position) { + case Position.Top: + return { + nodeId, + left: x, + top: 0, + width: horizontalWidth, + height: HANDLE_CROSS_AXIS_SIZE_PX, + transform: 'translate(-50%, -50%)', + }; + case Position.Bottom: + return { + nodeId, + left: x, + top: nodeHeight - HANDLE_CROSS_AXIS_SIZE_PX, + width: horizontalWidth, + height: HANDLE_CROSS_AXIS_SIZE_PX, + transform: 'translate(-50%, 50%)', + }; + case Position.Left: + return { + nodeId, + left: 0, + top: y, + width: HANDLE_CROSS_AXIS_SIZE_PX, + height: verticalHeight, + transform: 'translate(-50%, -50%)', + }; + case Position.Right: + return { + nodeId, + left: nodeWidth - HANDLE_CROSS_AXIS_SIZE_PX, + top: y, + width: HANDLE_CROSS_AXIS_SIZE_PX, + height: verticalHeight, + transform: 'translate(50%, -50%)', + }; + } +} + +export function getInwardHandleLayout( + position: Position, + handleType: HandleType +): InwardHandleLayout { + const notchOverlap = -INWARD_NOTCH_OVERLAP_PX[handleType]; + const anchorSize = { + width: INWARD_HANDLE_ANCHOR_SIZE_PX, + height: INWARD_HANDLE_ANCHOR_SIZE_PX, + }; + + switch (position) { + case Position.Left: + return { + rootTransform: 'translate(0, -50%)', + contentDirectionClassName: 'flex-row', + notchStyle: { marginLeft: notchOverlap }, + anchorStyle: { + ...anchorSize, + left: `calc(100% - ${INWARD_HANDLE_ANCHOR_RADIUS_PX}px)`, + top: '50%', + transform: 'translateY(-50%)', + }, + }; + case Position.Right: + return { + rootTransform: 'translate(0, -50%)', + contentDirectionClassName: 'flex-row-reverse', + notchStyle: { marginRight: notchOverlap }, + anchorStyle: { + ...anchorSize, + left: -INWARD_HANDLE_ANCHOR_RADIUS_PX, + top: '50%', + transform: 'translateY(-50%)', + }, + }; + case Position.Top: + return { + rootTransform: 'translate(-50%, 0)', + contentDirectionClassName: 'flex-col', + notchStyle: { marginTop: notchOverlap }, + anchorStyle: { + ...anchorSize, + left: '50%', + top: `calc(100% - ${INWARD_HANDLE_ANCHOR_RADIUS_PX}px)`, + transform: 'translateX(-50%)', + }, + }; + case Position.Bottom: + return { + rootTransform: 'translate(-50%, 0)', + contentDirectionClassName: 'flex-col-reverse', + notchStyle: { marginBottom: notchOverlap }, + anchorStyle: { + ...anchorSize, + left: '50%', + top: -INWARD_HANDLE_ANCHOR_RADIUS_PX, + transform: 'translateX(-50%)', + }, + }; + } +} diff --git a/packages/apollo-react/src/canvas/components/ButtonHandle/ButtonHandleStyleUtils.ts b/packages/apollo-react/src/canvas/components/ButtonHandle/ButtonHandleStyleUtils.ts index d0c17ba1b..361d13e1f 100644 --- a/packages/apollo-react/src/canvas/components/ButtonHandle/ButtonHandleStyleUtils.ts +++ b/packages/apollo-react/src/canvas/components/ButtonHandle/ButtonHandleStyleUtils.ts @@ -14,6 +14,11 @@ const isHorizontalEdge = (position: Position): boolean => const isVerticalEdge = (position: Position): boolean => position === Position.Left || position === Position.Right; +// Default handle hit areas cover half of the node edge, split across handles. +// The cross-axis remains fixed so labels/buttons align to a stable anchor. +export const HANDLE_CROSS_AXIS_SIZE_PX = 24; +export const HANDLE_EDGE_COVERAGE_RATIO = 0.5; + /** * Snaps a value to the nearest grid multiple * @param value - The value to snap @@ -112,7 +117,9 @@ export const widthForHandleWithPosition = ({ return `${customWidth}px`; } // Horizontal edges (Top/Bottom) scale width based on handle count; vertical edges use fixed width - return isHorizontalEdge(position) ? `${50 / numHandles}%` : '24px'; + return isHorizontalEdge(position) + ? `${(HANDLE_EDGE_COVERAGE_RATIO * 100) / numHandles}%` + : `${HANDLE_CROSS_AXIS_SIZE_PX}px`; }; export const heightForHandleWithPosition = ({ @@ -128,7 +135,9 @@ export const heightForHandleWithPosition = ({ return `${customHeight}px`; } // Horizontal edges (Top/Bottom) use fixed height; vertical edges scale height based on handle count - return isHorizontalEdge(position) ? '24px' : `${50 / numHandles}%`; + return isHorizontalEdge(position) + ? `${HANDLE_CROSS_AXIS_SIZE_PX}px` + : `${(HANDLE_EDGE_COVERAGE_RATIO * 100) / numHandles}%`; }; export const topPositionForHandle = ({ diff --git a/packages/apollo-react/src/canvas/components/ButtonHandle/HandleButton.tsx b/packages/apollo-react/src/canvas/components/ButtonHandle/HandleButton.tsx index dff4bbfa9..7fc796dad 100644 --- a/packages/apollo-react/src/canvas/components/ButtonHandle/HandleButton.tsx +++ b/packages/apollo-react/src/canvas/components/ButtonHandle/HandleButton.tsx @@ -2,6 +2,7 @@ import { Row } from '@uipath/apollo-react/canvas/layouts'; import { Position } from '@uipath/apollo-react/canvas/xyflow/react'; import { memo, useCallback, useEffect, useRef } from 'react'; import { cx } from '../../utils/CssUtil'; +import { NodeViewportOverlay } from '../NodeViewportOverlay'; import { CanvasInlineButton } from './CanvasInlineButton'; import { LABEL_SHADOW_STYLE } from './HandleLabel'; @@ -12,6 +13,16 @@ const BUTTON_POSITION: Record = { [Position.Right]: 'flex-row left-full top-1/2 -translate-y-1/2', }; const DRAG_THRESHOLD = 5; // px — movement before a click becomes a drag + +export type HandleButtonPortal = { + nodeId: string; + left: number; + top: number; + width: number; + height: number; + transform: string; +}; + /** * HandleButton — the "+" button on source handles (apollo-wind Button) * Click → fires onAction. @@ -30,6 +41,7 @@ export const HandleButton = memo( label, labelIcon, labelBackgroundColor, + portal, }: { visible?: boolean; labelVisible?: boolean; @@ -39,6 +51,7 @@ export const HandleButton = memo( label?: string; labelIcon?: React.ReactNode; labelBackgroundColor?: string; + portal?: HandleButtonPortal; }) => { const didDragRef = useRef(false); const teardownRef = useRef<(() => void) | null>(null); @@ -113,7 +126,7 @@ export const HandleButton = memo( teardownRef.current = cleanup; }, []); - return ( + const content = (
@@ -135,6 +148,18 @@ export const HandleButton = memo( )}
); + + if (portal) { + const { nodeId, ...anchor } = portal; + + return ( + + {content} + + ); + } + + return content; } ); diff --git a/packages/apollo-react/src/canvas/components/ButtonHandle/useButtonHandles.tsx b/packages/apollo-react/src/canvas/components/ButtonHandle/useButtonHandles.tsx index 85a70dae9..fa063d8ea 100644 --- a/packages/apollo-react/src/canvas/components/ButtonHandle/useButtonHandles.tsx +++ b/packages/apollo-react/src/canvas/components/ButtonHandle/useButtonHandles.tsx @@ -19,6 +19,7 @@ export const useButtonHandles = ({ shouldShowAddButtonFn, nodeWidth, nodeHeight, + portalActions, }: { handleConfigurations: HandleGroupManifest[]; shouldShowHandles: boolean; @@ -30,6 +31,7 @@ export const useButtonHandles = ({ showNotches?: boolean; nodeWidth?: number; nodeHeight?: number; + portalActions?: boolean; /** * Allows for consumers to control the predicate for showing the add button from the props that's passed in @@ -90,6 +92,7 @@ export const useButtonHandles = ({ shouldShowAddButtonFn={shouldShowAddButtonFn} nodeWidth={nodeWidth} nodeHeight={nodeHeight} + portalActions={portalActions} /> ); }); @@ -108,6 +111,7 @@ export const useButtonHandles = ({ shouldShowAddButtonFn, nodeWidth, nodeHeight, + portalActions, node?.data, ]); diff --git a/packages/apollo-react/src/canvas/components/Edges/SequenceEdge.tsx b/packages/apollo-react/src/canvas/components/Edges/SequenceEdge.tsx index c4c88d0dd..79ac558a1 100644 --- a/packages/apollo-react/src/canvas/components/Edges/SequenceEdge.tsx +++ b/packages/apollo-react/src/canvas/components/Edges/SequenceEdge.tsx @@ -1,8 +1,8 @@ import { type EdgeProps, Position } from '@uipath/apollo-react/canvas/xyflow/react'; import { memo, useRef, useState } from 'react'; -import { PREVIEW_EDGE_ID } from '../../constants'; import { useEdgeExecutionState, useEdgePath, useElementValidationStatus } from '../../hooks'; import type { NodeExecutionStateWithDebug } from '../../types/execution'; +import { isPreviewEdge } from '../../utils/createPreviewNode'; import { useBaseCanvasMode } from '../BaseCanvas/BaseCanvasModeProvider'; import { EdgeToolbar, useEdgeToolbarState } from '../Toolbar'; import { edgeTargetStatusToEdgeColor, getStatusAnimation } from './EdgeUtils'; @@ -71,7 +71,7 @@ export const SequenceEdge = memo(function SequenceEdge({ const { mode } = useBaseCanvasMode(); const isReadOnly = mode === 'readonly'; - const isPreviewEdge = id === PREVIEW_EDGE_ID; + const previewEdge = isPreviewEdge({ id, source, target }); const executionStatus = useEdgeExecutionState(id, target); const { validationStatus } = useElementValidationStatus(id) ?? { validationStatus: undefined }; @@ -130,7 +130,7 @@ export const SequenceEdge = memo(function SequenceEdge({ if (isDiffAdded) return 'var(--canvas-success-icon)'; if (isDiffRemoved) return 'var(--canvas-error-icon)'; - if (isPreviewEdge) return 'var(--canvas-primary)'; + if (previewEdge) return 'var(--canvas-primary)'; if (selected) return 'var(--canvas-primary)'; if (isHovered) return 'var(--canvas-primary-hover)'; if (status) return edgeTargetStatusToEdgeColor[status] ?? 'var(--canvas-border)'; @@ -182,7 +182,7 @@ export const SequenceEdge = memo(function SequenceEdge({ stroke: edgeColor, strokeDasharray: isDiffRemoved ? style?.strokeDasharray || '5,5' - : isPreviewEdge + : previewEdge ? '5,5' : '0', opacity: style?.opacity !== undefined ? style.opacity : 1, diff --git a/packages/apollo-react/src/canvas/components/Flow.stories.tsx b/packages/apollo-react/src/canvas/components/Flow.stories.tsx index 0b0dd7d9f..315f585d0 100644 --- a/packages/apollo-react/src/canvas/components/Flow.stories.tsx +++ b/packages/apollo-react/src/canvas/components/Flow.stories.tsx @@ -14,7 +14,7 @@ import { withCanvasProviders, } from '../storybook-utils'; import type { CanvasHandleActionEvent } from '../utils'; -import { applyPreviewToReactFlow, createPreviewNode } from '../utils/createPreviewNode'; +import { showPreviewGraph } from '../utils/createPreviewGraph'; import { AddNodeManager, AddNodePanel } from './AddNodePanel'; import { BaseCanvas } from './BaseCanvas'; import type { BaseNodeData } from './BaseNode/BaseNode.types'; @@ -319,21 +319,15 @@ function DefaultStory({ useSmartHandles }: FlowStoryArgs) { const sourceNode = reactFlowInstance.getNode(nodeId); const customData = sourceNode?.data?.useSmartHandles ? { useSmartHandles: true } : undefined; - const preview = createPreviewNode( - nodeId, - handleId, + showPreviewGraph({ + sourceNodeId: nodeId, + sourceHandleId: handleId, reactFlowInstance, - undefined, // No drop position - use auto-placement - customData, + data: customData, sourceHandleType, - undefined, // Use default preview node size - position as Position, - ['stickyNote'] // Ignore sticky notes when calculating overlap - ); - - if (preview) { - applyPreviewToReactFlow(preview, reactFlowInstance); - } + handlePosition: position as Position, + ignoredNodeTypes: ['stickyNote'], + }); } }); diff --git a/packages/apollo-react/src/canvas/components/HierarchicalCanvas/HierarchicalCanvas.tsx b/packages/apollo-react/src/canvas/components/HierarchicalCanvas/HierarchicalCanvas.tsx index db6144f94..93c096978 100644 --- a/packages/apollo-react/src/canvas/components/HierarchicalCanvas/HierarchicalCanvas.tsx +++ b/packages/apollo-react/src/canvas/components/HierarchicalCanvas/HierarchicalCanvas.tsx @@ -20,7 +20,7 @@ import { import { Spinner } from '@uipath/apollo-wind'; import type React from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { PREVIEW_EDGE_ID, PREVIEW_NODE_ID } from '../../constants'; +import { PREVIEW_NODE_ID } from '../../constants'; import { Breadcrumb } from '../../controls'; import { useNodeManifests } from '../../core'; import { useAddNodeOnConnectEnd } from '../../hooks/useAddNodeOnConnectEnd'; @@ -47,6 +47,7 @@ import { import { viewportManager } from '../../stores/viewportManager'; import { DefaultCanvasTranslations } from '../../types'; import type { CanvasLevel } from '../../types/canvas.types'; +import { isPreviewEdge } from '../../utils/createPreviewNode'; import { CanvasIcon } from '../../utils/icon-registry'; import { prefersReducedMotion } from '../../utils/transitions'; import { AddNodeManager } from '../AddNodePanel/AddNodeManager'; @@ -55,6 +56,7 @@ import { BaseCanvas, type BaseCanvasRef } from '../BaseCanvas'; import { BaseNode } from '../BaseNode'; import { BlankCanvasNode } from '../BlankCanvasNode'; import { CanvasPositionControls } from '../CanvasPositionControls'; +import { isContainerNodeManifest, LoopCanvasNode } from '../LoopNode'; import { MiniCanvasNavigator } from '../MiniCanvasNavigator'; interface HierarchicalCanvasProps { @@ -88,6 +90,22 @@ const DEFAULT_NODE_TYPES = { preview: AddNodePreview, } as const; +function shouldPersistNodeChange(change: NodeChange): boolean { + if (change.type === 'position') { + return !!change.dragging; + } + + if (change.type === 'dimensions') { + return !!change.setAttributes; + } + + return true; +} + +function isDefaultViewport(viewport: Viewport): boolean { + return viewport.x === 0 && viewport.y === 0 && viewport.zoom === 1; +} + export const HierarchicalCanvas: React.FC = ({ mode = 'design', initialCanvases, @@ -102,19 +120,16 @@ export const HierarchicalCanvas: React.FC = ({ const lastCanvasIdRef = useRef(null); const shouldAnimate = mode === 'design' && !prefersReducedMotion(); - // Build node types mapping from manifests and defaults + // Build node types mapping from manifests and defaults. const nodeManifests = useNodeManifests(); const nodeTypes = useMemo(() => { - const types = nodeManifests.reduce( + return nodeManifests.reduce( (acc, manifest) => { - if (!acc[manifest.nodeType]) { - acc[manifest.nodeType] = BaseNode; - } + acc[manifest.nodeType] = isContainerNodeManifest(manifest) ? LoopCanvasNode : BaseNode; return acc; }, { ...DEFAULT_NODE_TYPES } as NodeTypes ); - return types as NodeTypes; }, [nodeManifests]); // Optimized selectors to prevent unnecessary re-renders @@ -148,6 +163,7 @@ export const HierarchicalCanvas: React.FC = ({ const hasInitialized = useRef(false); // Initialize canvas on mount only - props are intentionally ignored after mount + // biome-ignore lint/correctness/useExhaustiveDependencies: initialization intentionally uses first prop values only useEffect(() => { if (hasInitialized.current) return; hasInitialized.current = true; @@ -157,7 +173,6 @@ export const HierarchicalCanvas: React.FC = ({ } else { initializeCanvas(); } - // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally only run on mount }, []); // Sync canvas changes back to consumer @@ -213,7 +228,7 @@ export const HierarchicalCanvas: React.FC = ({ if (reactFlowInstance && currentCanvas?.viewport) { const viewport = currentCanvas.viewport; // Only restore if viewport has been modified from defaults - if (viewport.x !== 0 || viewport.y !== 0 || viewport.zoom !== 1) { + if (!isDefaultViewport(viewport)) { // Use setTimeout to ensure React Flow has updated its internal state setTimeout(() => { reactFlowInstance.setViewport(viewport); @@ -242,18 +257,7 @@ export const HierarchicalCanvas: React.FC = ({ const canvas = currentCanvasRef.current; if (!canvas) return; - // Skip dimension-only changes to prevent infinite loops - // React Flow calls onNodesChange with dimension updates after measuring nodes - const hasMeaningfulChanges = changes.some( - (change) => change.type !== 'dimensions' && change.type !== 'position' - ); - - // For position changes, only update if the node was actually dragged (not just measured) - const hasPositionChanges = changes.some( - (change) => change.type === 'position' && change.dragging - ); - - if (!hasMeaningfulChanges && !hasPositionChanges) { + if (!changes.some(shouldPersistNodeChange)) { return; } @@ -309,7 +313,7 @@ export const HierarchicalCanvas: React.FC = ({ if (!connection.source || !connection.target || !canvas) return; // Don't create a connection to the preview node - if (connection.target === PREVIEW_NODE_ID || connection.source === PREVIEW_NODE_ID) { + if (isPreviewEdge(connection)) { return; } @@ -321,13 +325,15 @@ export const HierarchicalCanvas: React.FC = ({ targetHandle: connection.targetHandle || undefined, }; - updateEdges([...canvas.edges, newEdge]); - - // Remove any preview node/edge after successful connection const hasPreview = canvas.nodes.some((n) => n.id === PREVIEW_NODE_ID); + const baseEdges = hasPreview + ? canvas.edges.filter((edge) => !isPreviewEdge(edge)) + : canvas.edges; + + updateEdges([...baseEdges, newEdge]); + if (hasPreview) { updateNodes(canvas.nodes.filter((n) => n.id !== PREVIEW_NODE_ID)); - updateEdges(canvas.edges.filter((e) => e.id !== PREVIEW_EDGE_ID)); } }, [updateNodes, updateEdges] @@ -384,7 +390,7 @@ export const HierarchicalCanvas: React.FC = ({ // Only fit view if viewport is at default values (never been modified) const shouldFitView = useMemo(() => { const viewport = currentCanvas?.viewport; - return viewport ? viewport.x === 0 && viewport.y === 0 && viewport.zoom === 1 : false; + return viewport ? isDefaultViewport(viewport) : false; }, [currentCanvas?.viewport]); if (!currentCanvas) { diff --git a/packages/apollo-react/src/canvas/components/LoopNode/LoopCanvasNode.tsx b/packages/apollo-react/src/canvas/components/LoopNode/LoopCanvasNode.tsx new file mode 100644 index 000000000..9083fbc41 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/LoopNode/LoopCanvasNode.tsx @@ -0,0 +1,46 @@ +import type { Node, NodeProps } from '@uipath/apollo-react/canvas/xyflow/react'; +import { useReactFlow } from '@uipath/apollo-react/canvas/xyflow/react'; +import { memo, useCallback, useMemo } from 'react'; +import { useOptionalNodeTypeRegistry } from '../../core'; +import { LoopNode } from './LoopNode'; +import { resolveContainerPreviewConnectionHandles } from './LoopNode.helpers'; +import type { LoopNodeData } from './LoopNode.types'; +import { showCenteredContainerPreview } from './LoopNodePreview'; + +function LoopCanvasNodeComponent(props: NodeProps>) { + const reactFlow = useReactFlow(); + const nodeTypeRegistry = useOptionalNodeTypeRegistry(); + + const nodeManifest = useMemo( + () => (props.type ? nodeTypeRegistry?.getManifest(props.type) : undefined), + [nodeTypeRegistry, props.type] + ); + + const containerPreviewHandles = useMemo( + () => + resolveContainerPreviewConnectionHandles(nodeManifest, { + ...(props.data ?? {}), + nodeId: props.id, + }), + [nodeManifest, props.data, props.id] + ); + + const handleAddFirstChild = useCallback(() => { + if (!containerPreviewHandles) return; + + showCenteredContainerPreview({ + containerId: props.id, + reactFlowInstance: reactFlow, + previewHandles: containerPreviewHandles, + }); + }, [containerPreviewHandles, props.id, reactFlow]); + + return ( + + ); +} + +export const LoopCanvasNode = memo(LoopCanvasNodeComponent); diff --git a/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.constants.ts b/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.constants.ts new file mode 100644 index 000000000..1d3e17051 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.constants.ts @@ -0,0 +1,7 @@ +export const DEFAULT_LOOP_ICON = 'repeat'; +export const DEFAULT_LOOP_TITLE = 'Loop'; +export const DEFAULT_CONTAINER_HEADER_HEIGHT_PX = 40; + +export const DEFAULT_CONTAINER_MIN_WIDTH = 400; +export const DEFAULT_CONTAINER_MIN_HEIGHT = 220; +export const CONTAINER_FRAME_INSET_PX = 10; diff --git a/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.helpers.ts b/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.helpers.ts new file mode 100644 index 000000000..b504532e9 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.helpers.ts @@ -0,0 +1,149 @@ +import { type Node, Position } from '@uipath/apollo-react/canvas/xyflow/react'; +import { DEFAULT_CONTAINER_HEIGHT, DEFAULT_CONTAINER_WIDTH } from '../../constants'; +import type { NodeManifest } from '../../schema/node-definition'; +import { getOppositePosition } from '../../utils/createPreviewNode'; +import type { ResolutionContext, ResolvedHandleGroup } from '../../utils/manifest-resolver'; +import { resolveHandles } from '../../utils/manifest-resolver'; +import { snapToGrid } from '../../utils/NodeUtils'; +import { CONTAINER_FRAME_INSET_PX, DEFAULT_CONTAINER_HEADER_HEIGHT_PX } from './LoopNode.constants'; + +export type ContainerHandleBoundary = 'outer' | 'inner'; +export type ContainerHandleGroup = ResolvedHandleGroup & { + boundary: ContainerHandleBoundary; + connectionPosition: Position; +}; + +export interface ContainerPreviewConnectionHandles { + sourceHandleId: string; + sourceHandlePosition: Position; + targetHandleId: string; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +export function isContainerNodeManifest( + manifest: Pick | undefined +): boolean { + return manifest?.display.shape === 'container'; +} + +export function resolveContainerHandleGroups( + groups: ResolvedHandleGroup[] +): ContainerHandleGroup[] { + return groups.map((group) => { + const boundary = group.boundary ?? 'outer'; + const position = group.position as Position; + + return { + ...group, + boundary, + connectionPosition: boundary === 'inner' ? getOppositePosition(position) : position, + customPositionAndOffsets: + boundary === 'inner' ? insetInnerGroup(group) : group.customPositionAndOffsets, + }; + }); +} + +export function getContainerBodyCenter({ + width, + height, + headerHeight, +}: { + width: number; + height: number; + headerHeight: number; +}) { + const clampedHeaderHeight = clamp(headerHeight, 0, height); + + return { + x: clamp(snapToGrid(width / 2), 0, width), + y: clamp(snapToGrid(clampedHeaderHeight + (height - clampedHeaderHeight) / 2), 0, height), + }; +} + +export function getContainerRelativeBodyCenter( + containerNode: Pick +) { + const width = readNumericDimension( + containerNode.width, + containerNode.measured?.width, + containerNode.style?.width + ); + const height = readNumericDimension( + containerNode.height, + containerNode.measured?.height, + containerNode.style?.height + ); + + return getContainerBodyCenter({ + width: width ?? DEFAULT_CONTAINER_WIDTH, + height: height ?? DEFAULT_CONTAINER_HEIGHT, + headerHeight: DEFAULT_CONTAINER_HEADER_HEIGHT_PX, + }); +} + +function insetInnerGroup(group: ResolvedHandleGroup) { + const offsets = group.customPositionAndOffsets ?? {}; + + switch (group.position) { + case Position.Left: + return { ...offsets, left: (offsets.left ?? 0) + CONTAINER_FRAME_INSET_PX }; + case Position.Right: + return { ...offsets, right: (offsets.right ?? 0) + CONTAINER_FRAME_INSET_PX }; + case Position.Top: + return { ...offsets, top: (offsets.top ?? 0) + CONTAINER_FRAME_INSET_PX }; + case Position.Bottom: + return { ...offsets, bottom: (offsets.bottom ?? 0) + CONTAINER_FRAME_INSET_PX }; + default: + return offsets; + } +} + +function readNumericDimension(...values: Array): number | undefined { + for (const value of values) { + if (typeof value === 'number') return value; + if (typeof value === 'string') { + const parsedValue = Number.parseFloat(value); + if (Number.isFinite(parsedValue)) return parsedValue; + } + } + + return undefined; +} + +export function resolveContainerPreviewConnectionHandles( + manifest: Pick | undefined, + context: ResolutionContext +): ContainerPreviewConnectionHandles | null { + if (!manifest) return null; + + const innerGroups = resolveHandles(manifest.handleConfiguration, context).filter( + (group) => group.boundary === 'inner' && (group.visible ?? true) + ); + const sourceHandle = pickPreferredInnerHandle(innerGroups, 'source'); + const targetHandle = pickPreferredInnerHandle(innerGroups, 'target'); + + if (!sourceHandle || !targetHandle) return null; + + return { + sourceHandleId: sourceHandle.handle.id, + sourceHandlePosition: getOppositePosition(sourceHandle.group.position as Position), + targetHandleId: targetHandle.handle.id, + }; +} + +function pickPreferredInnerHandle( + groups: ResolvedHandleGroup[], + type: 'source' | 'target' +): { group: ResolvedHandleGroup; handle: ResolvedHandleGroup['handles'][number] } | null { + for (const group of groups) { + const handle = group.handles.find((candidate) => candidate.type === type && candidate.visible); + if (handle) { + return { group, handle }; + } + } + + return null; +} diff --git a/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.stories.tsx b/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.stories.tsx new file mode 100644 index 000000000..e84d91af0 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.stories.tsx @@ -0,0 +1,204 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { + type Edge, + type Node, + Panel, + type Position, + useReactFlow, +} from '@uipath/apollo-react/canvas/xyflow/react'; +import { useCallback, useMemo } from 'react'; +import { useAddNodeOnConnectEnd, useCanvasEvent } from '../../hooks'; +import { createNode, useCanvasStory, withCanvasProviders } from '../../storybook-utils'; +import { DefaultCanvasTranslations } from '../../types'; +import type { CanvasHandleActionEvent } from '../../utils'; +import { removePreviewFromReactFlow } from '../../utils/createPreviewNode'; +import { snapToGrid } from '../../utils/NodeUtils'; +import { AddNodeManager } from '../AddNodePanel'; +import { createAddNodePreview } from '../AddNodePanel/createAddNodePreview'; +import { BaseCanvas } from '../BaseCanvas'; +import type { BaseNodeData } from '../BaseNode/BaseNode.types'; +import { CanvasPositionControls } from '../CanvasPositionControls'; +import type { LoopNodeData } from './LoopNode.types'; + +const meta: Meta = { + title: 'Canvas/LoopNode', + parameters: { layout: 'fullscreen' }, + decorators: [withCanvasProviders()], +}; + +export default meta; +type Story = StoryObj; + +const LOOP_TYPE = 'uipath.control-flow.foreach'; +const ACTIVITY_TYPE = 'uipath.blank-node'; +const STORY_LOOP_START_HANDLE_ID = 'start'; +const STORY_LOOP_CONTINUE_HANDLE_ID = 'continue'; + +const snapPoint = (point: { x: number; y: number }) => ({ + x: snapToGrid(point.x), + y: snapToGrid(point.y), +}); + +const snapSize = (size: { width: number; height: number }) => ({ + width: snapToGrid(size.width), + height: snapToGrid(size.height), +}); + +function createLoopContainerNode( + id: string, + position: { x: number; y: number }, + size: { width: number; height: number }, + options?: { selected?: boolean; data?: LoopNodeData } +): Node { + const snappedSize = snapSize(size); + const display = { + ...options?.data?.display, + shape: 'container' as const, + }; + + return { + id, + type: LOOP_TYPE, + position: snapPoint(position), + selected: options?.selected ?? false, + data: { + ...options?.data, + display, + }, + style: { width: snappedSize.width, height: snappedSize.height }, + }; +} + +function createActivityNode( + id: string, + label: string, + position: { x: number; y: number }, + options?: { parentId?: string; subLabel?: string | null } +): Node { + const node = createNode({ + id, + type: ACTIVITY_TYPE, + position: snapPoint(position), + display: options?.subLabel ? { label, subLabel: options.subLabel } : { label }, + }); + + if (options?.parentId) { + return { + ...node, + parentId: options.parentId, + }; + } + + return node; +} + +function DefaultStory() { + const reactFlow = useReactFlow(); + const handleAddNodeOnConnectEnd = useAddNodeOnConnectEnd(); + + const initialNodes = useMemo( + () => [ + createActivityNode('ingress', 'Load records', { x: 32, y: 256 }), + createLoopContainerNode( + 'loop-1', + { x: 224, y: 128 }, + { width: 704, height: 368 }, + { + selected: true, + data: { display: { label: 'For Each claim' } }, + } + ), + createActivityNode('child-1', 'Analyze claims', { x: 160, y: 96 }, { parentId: 'loop-1' }), + createActivityNode('child-2', 'Write outcome', { x: 432, y: 96 }, { parentId: 'loop-1' }), + createActivityNode('egress', 'Publish results', { x: 1024, y: 256 }), + ], + [] + ); + + const initialEdges = useMemo( + () => [ + { + id: 'ingress-loop', + source: 'ingress', + sourceHandle: 'output', + target: 'loop-1', + targetHandle: 'input', + }, + { + id: 'loop-child-1', + source: 'loop-1', + sourceHandle: STORY_LOOP_START_HANDLE_ID, + target: 'child-1', + targetHandle: 'input', + }, + { + id: 'child-1-child-2', + source: 'child-1', + sourceHandle: 'output', + target: 'child-2', + targetHandle: 'input', + }, + { + id: 'child-2-loop', + source: 'child-2', + sourceHandle: 'output', + target: 'loop-1', + targetHandle: STORY_LOOP_CONTINUE_HANDLE_ID, + }, + { + id: 'loop-egress', + source: 'loop-1', + sourceHandle: 'success', + target: 'egress', + targetHandle: 'input', + }, + ], + [] + ); + + const { canvasProps } = useCanvasStory({ + initialNodes, + initialEdges, + }); + + const handleHandleAction = useCallback( + (event: CanvasHandleActionEvent) => { + const { handleId, nodeId, position, handleType } = event; + if (!handleId || !nodeId) return; + + createAddNodePreview( + nodeId, + handleId, + reactFlow, + position as Position, + handleType === 'input' ? 'target' : 'source' + ); + }, + [reactFlow] + ); + + useCanvasEvent('handle:action', handleHandleAction); + + const handlePaneClick = useCallback(() => { + removePreviewFromReactFlow(reactFlow); + }, [reactFlow]); + + return ( + + + + + + + ); +} + +export const Default: Story = { + render: () => , +}; diff --git a/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.tsx b/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.tsx new file mode 100644 index 000000000..b62b0c066 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.tsx @@ -0,0 +1,549 @@ +import { + NodeResizeControl, + type Position, + type ReactFlowState, + useStore, + useUpdateNodeInternals, +} from '@uipath/apollo-react/canvas/xyflow/react'; +import { cn } from '@uipath/apollo-wind'; +import { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { DEFAULT_CONTAINER_HEIGHT, DEFAULT_CONTAINER_WIDTH } from '../../constants'; +import { useOptionalNodeTypeRegistry } from '../../core'; +import { useElementValidationStatus, useNodeExecutionState } from '../../hooks'; +import type { SuggestionType } from '../../types'; +import { resolveAdornments } from '../../utils/adornment-resolver'; +import { CanvasIcon } from '../../utils/icon-registry'; +import { resolveDisplay, resolveHandles } from '../../utils/manifest-resolver'; +import { selectIsConnecting, snapToGrid } from '../../utils/NodeUtils'; +import { resolveToolbar } from '../../utils/toolbar-resolver'; +import { useBaseCanvasMode } from '../BaseCanvas/BaseCanvasModeProvider'; +import { useConnectedHandles } from '../BaseCanvas/ConnectedHandlesContext'; +import { useSelectionState } from '../BaseCanvas/SelectionStateContext'; +import type { NodeAdornments, NodeStatusContext } from '../BaseNode/BaseNode.types'; +import { BaseBadgeSlot } from '../BaseNode/BaseNodeBadgeSlot'; +import { getStatusBorder } from '../BaseNode/BaseNodeContainer'; +import { MissingManifestNode } from '../BaseNode/BaseNodeMissingManifest'; +import type { HandleActionEvent } from '../ButtonHandle'; +import { ButtonHandles } from '../ButtonHandle'; +import { NodeToolbar } from '../Toolbar'; +import { + DEFAULT_CONTAINER_MIN_HEIGHT, + DEFAULT_CONTAINER_MIN_WIDTH, + DEFAULT_LOOP_ICON, + DEFAULT_LOOP_TITLE, +} from './LoopNode.constants'; +import { type ContainerHandleGroup, resolveContainerHandleGroups } from './LoopNode.helpers'; +import type { LoopNodeProps } from './LoopNode.types'; + +const EMPTY_DATA: Record = {}; + +const RESIZE_CONTROLS = [ + { position: 'top-left', cursor: 'nwse-resize', indicatorClassName: 'top-[-4px] left-[-4px]' }, + { position: 'top-right', cursor: 'nesw-resize', indicatorClassName: 'top-[-4px] right-[-4px]' }, + { + position: 'bottom-left', + cursor: 'nesw-resize', + indicatorClassName: 'bottom-[-4px] left-[-4px]', + }, + { + position: 'bottom-right', + cursor: 'nwse-resize', + indicatorClassName: 'bottom-[-4px] right-[-4px]', + }, +] as const; +const RESIZE_CONTROL_STYLE = { background: 'transparent', border: 'none', zIndex: 100 } as const; + +const ADORNMENT_SLOT_POSITIONS = ['topLeft', 'topRight', 'bottomLeft', 'bottomRight'] as const; +const ADORNMENT_SLOT_SHAPES: Record< + (typeof ADORNMENT_SLOT_POSITIONS)[number], + 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' +> = { + topLeft: 'top-left', + topRight: 'top-right', + bottomLeft: 'bottom-left', + bottomRight: 'bottom-right', +}; + +function resolveInteractionState( + dragging: boolean, + selected: boolean, + isHovered: boolean +): 'drag' | 'selected' | 'hover' | 'default' { + if (dragging) return 'drag'; + if (selected) return 'selected'; + if (isHovered) return 'hover'; + return 'default'; +} + +function useHasChildNodes(id: string, enabled: boolean): boolean { + return useStore( + useCallback( + (state: ReactFlowState) => !enabled || state.nodes.some((node) => node.parentId === id), + [id, enabled] + ) + ); +} + +function useContainerNodeInternalsRefresh( + id: string, + handleGroups: ContainerHandleGroup[], + width: number, + height: number +) { + const updateNodeInternals = useUpdateNodeInternals(); + + // biome-ignore lint/correctness/useExhaustiveDependencies: dimensions and resolved handle groups are intentional rerun triggers for React Flow internals recalculation + useEffect(() => { + const frameId = requestAnimationFrame(() => { + updateNodeInternals(id); + }); + + return () => { + cancelAnimationFrame(frameId); + }; + }, [id, handleGroups, updateNodeInternals, width, height]); +} + +function LoopNodeComponent(props: LoopNodeProps) { + const { + id, + type, + data, + selected = false, + dragging = false, + width = 0, + height = 0, + onAddFirstChild, + onResize, + toolbarConfig: toolbarConfigProp, + adornments: adornmentsProp, + executionStatusOverride, + suggestionType: suggestionTypeProp, + } = props; + const nodeTypeRegistry = useOptionalNodeTypeRegistry(); + const [isHovered, setIsHovered] = useState(false); + + const resolvedData = data ?? EMPTY_DATA; + const isLoading = !!resolvedData.loading; + const suggestionType = + suggestionTypeProp ?? (resolvedData as { suggestionType?: SuggestionType }).suggestionType; + const manifest = useMemo(() => nodeTypeRegistry?.getManifest(type), [nodeTypeRegistry, type]); + const { mode } = useBaseCanvasMode(); + const isDesignMode = mode === 'design'; + const connectedHandleIds = useConnectedHandles(id); + const { multipleNodesSelected } = useSelectionState(); + const isConnecting = useStore(selectIsConnecting); + const hasChildNodes = useHasChildNodes(id, isDesignMode && !!onAddFirstChild); + + const executionState = useNodeExecutionState(id); + const validationState = useElementValidationStatus(id); + + const statusContext: NodeStatusContext = useMemo( + () => ({ + nodeId: id, + executionState: executionStatusOverride ?? executionState, + validationState, + isConnecting, + isSelected: selected, + isDragging: dragging, + mode, + }), + [ + dragging, + executionStatusOverride, + executionState, + id, + isConnecting, + mode, + selected, + validationState, + ] + ); + + const executionStatus = + executionStatusOverride ?? + (typeof executionState === 'string' ? executionState : executionState?.status); + + const display = useMemo( + () => resolveDisplay(manifest?.display, { ...resolvedData, nodeId: id }), + [manifest?.display, id, resolvedData] + ); + + const displayTitle = display.label ?? DEFAULT_LOOP_TITLE; + const displayIcon = display.icon ?? DEFAULT_LOOP_ICON; + const isParallel = resolvedData.parallel === true; + const isDropTarget = resolvedData.isDropTarget === true; + const containerWidth = width || DEFAULT_CONTAINER_WIDTH; + const containerHeight = height || DEFAULT_CONTAINER_HEIGHT; + const nodeSizeStyle = { + width: containerWidth, + height: containerHeight, + minWidth: DEFAULT_CONTAINER_MIN_WIDTH, + minHeight: DEFAULT_CONTAINER_MIN_HEIGHT, + }; + + const toolbarConfig = useMemo(() => { + if (toolbarConfigProp !== undefined) { + return toolbarConfigProp === null ? undefined : toolbarConfigProp; + } + + return manifest ? resolveToolbar(manifest, statusContext, data) : undefined; + }, [data, manifest, statusContext, toolbarConfigProp]); + + const adornments: NodeAdornments = useMemo( + () => ({ + ...resolveAdornments(statusContext), + ...(adornmentsProp ?? {}), + }), + [adornmentsProp, statusContext] + ); + + const resolvedHandleGroups = useMemo( + () => (manifest ? resolveHandles(manifest.handleConfiguration, resolvedData) : []), + [manifest, resolvedData] + ); + const containerHandleGroups = useMemo( + () => resolveContainerHandleGroups(resolvedHandleGroups), + [resolvedHandleGroups] + ); + + useContainerNodeInternalsRefresh(id, containerHandleGroups, containerWidth, containerHeight); + + const handleResize = useCallback( + (_event: unknown, params: { width: number; height: number }) => { + onResize?.({ + width: snapToGrid(params.width), + height: snapToGrid(params.height), + }); + }, + [onResize] + ); + + const handleEmptyClick = useCallback(() => { + onAddFirstChild?.(); + }, [onAddFirstChild]); + + const handleMouseEnter = useCallback(() => setIsHovered(true), []); + const handleMouseLeave = useCallback(() => setIsHovered(false), []); + const handleOuterHandleAction = useCallback((_event: HandleActionEvent) => { + setIsHovered(false); + }, []); + + const shouldShowHandles = (isConnecting || selected || isHovered) && !dragging; + + const showHandleAddButtons = isDesignMode && !multipleNodesSelected && !isConnecting && !dragging; + const showResizeControls = selected && !dragging && isDesignMode; + const showEmptyStateButton = isDesignMode && !hasChildNodes && !!onAddFirstChild; + + const interactionState = resolveInteractionState(dragging, selected, isHovered); + + if (!manifest) { + return ( + // biome-ignore lint/a11y/noStaticElementInteractions: canvas node hover state is mouse-driven +
+ +
+ ); + } + + return ( + // biome-ignore lint/a11y/noStaticElementInteractions: canvas node hover state is mouse-driven +
+ {ADORNMENT_SLOT_POSITIONS.map((slot) => + adornments?.[slot] ? ( + + {adornments[slot]} + + ) : null + )} + + {showResizeControls ? : null} +
+ + {toolbarConfig && ( +
+ ); +} + +export const LoopNode = memo(LoopNodeComponent); + +function Header({ + title, + icon, + loading, + isParallel, +}: { + title: string; + icon?: string; + loading: boolean; + isParallel: boolean; +}) { + const titleContent = loading ? ( +
+ ) : ( + {title} + ); + + const iconContent = loading ? ( +
+ ) : icon ? ( + + + + ) : null; + + return ( +
+
+ {iconContent} + {titleContent} +
+ + + + + {isParallel ? 'Parallel' : 'Sequential'} + +
+ ); +} + +function EmptyState({ onAddFirstChild }: { onAddFirstChild: () => void }) { + return ( + + ); +} + +function BodyFrame({ + isEmpty, + isLoading, + onAddFirstChild, +}: { + isEmpty?: boolean; + isLoading?: boolean; + onAddFirstChild: () => void; +}) { + return ( +
+ {isLoading ? ( +
+ ) : null} + {isEmpty ? : null} +
+ ); +} + +function ResizeControls({ + onResize, +}: { + onResize: (_event: unknown, params: { width: number; height: number }) => void; +}) { + return ( + <> + {RESIZE_CONTROLS.map(({ position, cursor }) => ( + +
+ + ))} + + ); +} + +function ResizeCornerIndicators({ visible }: { visible: boolean }) { + return ( + <> + {RESIZE_CONTROLS.map(({ position, indicatorClassName }) => ( +
+ ))} + + ); +} + +type SharedHandleGroupProps = { + nodeId: string; + selected: boolean; + hovered: boolean; + shouldShowHandles: boolean; + showAddButton: boolean; + showNotches: boolean; + nodeWidth: number; + nodeHeight: number; + connectedHandleIds: ReadonlySet; + onOuterHandleAction: (event: HandleActionEvent) => void; +}; + +type HandleGroupsProps = SharedHandleGroupProps & { + groups: ContainerHandleGroup[]; +}; + +function HandleGroups({ groups, ...handleGroupProps }: HandleGroupsProps) { + if (groups.length === 0) return null; + + return ( + <> + {groups.map((group, groupIndex) => ( + + ))} + + ); +} + +type HandleGroupProps = SharedHandleGroupProps & { + group: ContainerHandleGroup; +}; + +function HandleGroup({ + nodeId, + group, + selected, + hovered, + shouldShowHandles, + showAddButton, + showNotches, + nodeWidth, + nodeHeight, + connectedHandleIds, + onOuterHandleAction, +}: HandleGroupProps) { + const groupVisible = shouldShowHandles && (group.visible ?? true); + const position = group.position as Position; + const enhancedHandles = useMemo( + () => + group.handles.map((handle) => { + const isInnerSourceHandle = group.boundary === 'inner' && handle.type === 'source'; + const shouldResetHoverOnAction = + group.boundary === 'outer' && handle.type === 'source' && handle.showButton; + + return { + ...handle, + showHandle: connectedHandleIds.has(handle.id) || groupVisible, + showButton: isInnerSourceHandle ? false : handle.showButton, + onAction: handle.onAction ?? (shouldResetHoverOnAction ? onOuterHandleAction : undefined), + }; + }), + [group.boundary, group.handles, connectedHandleIds, groupVisible, onOuterHandleAction] + ); + + return ( + + ); +} diff --git a/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.types.ts b/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.types.ts new file mode 100644 index 000000000..4026d230d --- /dev/null +++ b/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.types.ts @@ -0,0 +1,25 @@ +import type { Node, NodeProps } from '@uipath/apollo-react/canvas/xyflow/react'; +import type { SuggestionType } from '../../types'; +import type { ElementStatusValues } from '../../types/execution'; +import type { BaseNodeData } from '../BaseNode'; +import type { NodeAdornments } from '../BaseNode/BaseNode.types'; +import type { NodeToolbarConfig } from '../Toolbar'; + +export type LoopNodeData = BaseNodeData; + +export interface LoopNodeResizeSize { + width: number; + height: number; +} + +export interface LoopNodeConfig { + toolbarConfig?: NodeToolbarConfig | null; + adornments?: NodeAdornments; + executionStatusOverride?: ElementStatusValues; + suggestionType?: SuggestionType; +} + +export interface LoopNodeProps extends NodeProps>, LoopNodeConfig { + onAddFirstChild?: () => void; + onResize?: (size: LoopNodeResizeSize) => void; +} diff --git a/packages/apollo-react/src/canvas/components/LoopNode/LoopNodePreview.ts b/packages/apollo-react/src/canvas/components/LoopNode/LoopNodePreview.ts new file mode 100644 index 000000000..657505c6d --- /dev/null +++ b/packages/apollo-react/src/canvas/components/LoopNode/LoopNodePreview.ts @@ -0,0 +1,43 @@ +import type { ReactFlowInstance } from '@uipath/apollo-react/canvas/xyflow/react'; +import { showPreviewGraph } from '../../utils/createPreviewGraph'; +import { getAbsolutePosition, snapToGrid } from '../../utils/NodeUtils'; +import { + type ContainerPreviewConnectionHandles, + getContainerRelativeBodyCenter, +} from './LoopNode.helpers'; + +export function showCenteredContainerPreview({ + containerId, + reactFlowInstance, + previewHandles, + trailingEdgeId, +}: { + containerId: string; + reactFlowInstance: ReactFlowInstance; + previewHandles: ContainerPreviewConnectionHandles; + trailingEdgeId?: string; +}) { + const containerNode = reactFlowInstance.getNode(containerId); + if (!containerNode) return; + + const allNodes = reactFlowInstance.getNodes(); + const containerAbsolutePosition = getAbsolutePosition(containerNode, allNodes); + const relativeCenter = getContainerRelativeBodyCenter(containerNode); + const previewCenter = { + x: snapToGrid(containerAbsolutePosition.x + relativeCenter.x), + y: snapToGrid(containerAbsolutePosition.y + relativeCenter.y), + }; + + showPreviewGraph({ + sourceNodeId: containerId, + sourceHandleId: previewHandles.sourceHandleId, + reactFlowInstance, + position: previewCenter, + positionMode: 'center', + handlePosition: previewHandles.sourceHandlePosition, + targetNodeId: containerId, + targetHandleId: previewHandles.targetHandleId, + containerId, + trailingEdgeId, + }); +} diff --git a/packages/apollo-react/src/canvas/components/LoopNode/index.ts b/packages/apollo-react/src/canvas/components/LoopNode/index.ts new file mode 100644 index 000000000..cc5ed83ef --- /dev/null +++ b/packages/apollo-react/src/canvas/components/LoopNode/index.ts @@ -0,0 +1,4 @@ +export * from './LoopCanvasNode'; +export * from './LoopNode'; +export * from './LoopNode.helpers'; +export * from './LoopNode.types'; diff --git a/packages/apollo-react/src/canvas/components/NodeViewportOverlay.tsx b/packages/apollo-react/src/canvas/components/NodeViewportOverlay.tsx new file mode 100644 index 000000000..3cec5acce --- /dev/null +++ b/packages/apollo-react/src/canvas/components/NodeViewportOverlay.tsx @@ -0,0 +1,56 @@ +import { useInternalNode, ViewportPortal } from '@uipath/apollo-react/canvas/xyflow/react'; +import type { CSSProperties, ReactNode } from 'react'; + +// React Flow adds 1000 to selected node z-index and styles its connection line at 1001. +// Node viewport overlays sit just above those library layers. +const NODE_VIEWPORT_OVERLAY_Z_INDEX = { + nodeHandleAffordance: 1002, + nodeToolbar: 1003, +} as const; + +type NodeViewportOverlayLayer = keyof typeof NODE_VIEWPORT_OVERLAY_Z_INDEX; + +export type NodeViewportOverlayAnchor = { + left?: number; + top?: number; + width?: number; + height?: number; + transform?: CSSProperties['transform']; +}; + +export type NodeViewportOverlayProps = { + nodeId: string; + anchor?: NodeViewportOverlayAnchor; + layer?: NodeViewportOverlayLayer; + children: ReactNode; +}; + +export function NodeViewportOverlay({ nodeId, anchor, layer, children }: NodeViewportOverlayProps) { + const internalNode = useInternalNode(nodeId); + const positionAbsolute = internalNode?.internals.positionAbsolute; + const width = anchor?.width ?? internalNode?.measured?.width ?? internalNode?.width; + const height = anchor?.height ?? internalNode?.measured?.height ?? internalNode?.height; + + if (!positionAbsolute || width == null || height == null) { + return children; + } + + return ( + +
+ {children} +
+
+ ); +} diff --git a/packages/apollo-react/src/canvas/components/Toolbar/EdgeToolbar/EdgeToolbar.tsx b/packages/apollo-react/src/canvas/components/Toolbar/EdgeToolbar/EdgeToolbar.tsx index 96079439a..2e61c9199 100644 --- a/packages/apollo-react/src/canvas/components/Toolbar/EdgeToolbar/EdgeToolbar.tsx +++ b/packages/apollo-react/src/canvas/components/Toolbar/EdgeToolbar/EdgeToolbar.tsx @@ -24,7 +24,7 @@ const EdgeToolbarComponent = ({ {visible && ( ({ })); vi.mock('../../../utils/createPreviewNode', () => ({ - createPreviewNode: vi.fn( - ( - source, - sourceHandleId, - _reactFlow, - position, - _metadata, - _handleType, - _nodeSize, - _sourcePosition, - _ignoredNodeTypes - ) => ({ - node: { - id: PREVIEW_NODE_ID, - type: 'default', - position, - data: {}, - }, - edge: { - id: `${source}-${PREVIEW_NODE_ID}`, - source, - sourceHandle: sourceHandleId, - target: PREVIEW_NODE_ID, - targetHandle: 'input', - type: 'default', - }, - }) + isPreviewEdge: vi.fn( + ({ source, target }: { id: string; source: string; target: string }) => + source === PREVIEW_NODE_ID || target === PREVIEW_NODE_ID ), - applyPreviewToReactFlow: vi.fn(), +})); + +vi.mock('../../../utils/createPreviewGraph', () => ({ + showPreviewGraph: vi.fn(), })); const { useEdgeToolbarPositioning } = await import('./useEdgeToolbarPositioning'); -const { createPreviewNode, applyPreviewToReactFlow } = await import( - '../../../utils/createPreviewNode' -); +const { showPreviewGraph } = await import('../../../utils/createPreviewGraph'); describe('useEdgeToolbarState', () => { const defaultProps = { @@ -257,27 +234,29 @@ describe('useEdgeToolbarState', () => { result.current.config.actions[0]?.onAction('edge-1', position); }); - expect(createPreviewNode).toHaveBeenCalledWith( - 'node-1', - 'output', - mockReactFlowInstance, + expect(showPreviewGraph).toHaveBeenCalledWith({ + sourceNodeId: 'node-1', + sourceHandleId: 'output', + reactFlowInstance: mockReactFlowInstance, position, - expect.objectContaining({ + data: expect.objectContaining({ originalEdge: expect.objectContaining({ id: 'edge-1', source: 'node-1', target: 'node-2', }), }), - 'source', - undefined, - Position.Right, - undefined // ignoredNodeTypes - ); + sourceHandleType: 'source', + handlePosition: Position.Right, + ignoredNodeTypes: [], + targetNodeId: 'node-2', + targetHandleId: 'input', + removedEdgeIds: ['edge-1'], + }); }); - it('should not create nodes if createPreviewNode returns null', () => { - vi.mocked(createPreviewNode).mockReturnValueOnce(null); + it('should not mutate edges directly if preview graph creation returns null', () => { + vi.mocked(showPreviewGraph).mockReturnValueOnce(null); const { result } = renderHook(() => useEdgeToolbarState({ @@ -290,7 +269,7 @@ describe('useEdgeToolbarState', () => { result.current.config.actions[0]?.onAction('edge-1', { x: 150, y: 100 }); }); - expect(applyPreviewToReactFlow).not.toHaveBeenCalled(); + expect(showPreviewGraph).toHaveBeenCalledOnce(); expect(mockReactFlowInstance.setEdges).not.toHaveBeenCalled(); }); }); diff --git a/packages/apollo-react/src/canvas/components/Toolbar/EdgeToolbar/useEdgeToolbarState.ts b/packages/apollo-react/src/canvas/components/Toolbar/EdgeToolbar/useEdgeToolbarState.ts index fcebb2271..00b2f853a 100644 --- a/packages/apollo-react/src/canvas/components/Toolbar/EdgeToolbar/useEdgeToolbarState.ts +++ b/packages/apollo-react/src/canvas/components/Toolbar/EdgeToolbar/useEdgeToolbarState.ts @@ -1,7 +1,8 @@ import { type Edge, type Position, useReactFlow } from '@uipath/apollo-react/canvas/xyflow/react'; import { useCallback, useMemo } from 'react'; -import { PREVIEW_NODE_ID } from '../../../constants'; -import { applyPreviewToReactFlow, createPreviewNode } from '../../../utils/createPreviewNode'; +import { DEFAULT_SOURCE_HANDLE_ID } from '../../../constants'; +import { showPreviewGraph } from '../../../utils/createPreviewGraph'; +import { isPreviewEdge } from '../../../utils/createPreviewNode'; import { useBaseCanvasMode } from '../../BaseCanvas/BaseCanvasModeProvider'; import type { EdgeToolbarConfig, EdgeToolbarPositionData } from './EdgeToolbar.types'; import { useEdgeToolbarPositioning } from './useEdgeToolbarPositioning'; @@ -42,12 +43,12 @@ export function useEdgeToolbarState({ const { mode } = useBaseCanvasMode(); const isDesignMode = mode === 'design'; - const isPreviewEdge = source === PREVIEW_NODE_ID || target === PREVIEW_NODE_ID; + const previewEdge = isPreviewEdge({ id: edgeId, source, target }); // Only track mouse position when hovering and in design mode (not on preview edges) const { positionData, handleMouseMoveOnPath } = useEdgeToolbarPositioning({ pathElementRef, - isEnabled: isHovered && isDesignMode && !isPreviewEdge, + isEnabled: isHovered && isDesignMode && !previewEdge, targetPosition, }); @@ -64,39 +65,19 @@ export function useEdgeToolbarState({ type: 'default', }; - // Use createPreviewNode utility to create preview node with proper positioning - const preview = createPreviewNode( - source, - sourceHandleId || 'output', - reactFlow, + showPreviewGraph({ + sourceNodeId: source, + sourceHandleId: sourceHandleId ?? DEFAULT_SOURCE_HANDLE_ID, + reactFlowInstance: reactFlow, position, // Drop position at mouse cursor - { originalEdge }, // Pass original edge to restore if cancelled - 'source', // Source handle type - undefined, // Use default node size - sourcePosition, - ignoredNodeTypes - ); - - if (!preview) return; - - // Create second edge from preview to target - const secondEdge: Edge = { - id: `${PREVIEW_NODE_ID}-${target}`, - source: PREVIEW_NODE_ID, - sourceHandle: 'output', - target, - targetHandle: targetHandleId, - type: 'default', - }; - - // Apply preview (adds preview node and first edge: source → preview) - applyPreviewToReactFlow(preview, reactFlow); - - // Remove original edge and add second edge (preview → target) - reactFlow.setEdges((edges) => [ - ...edges.filter((e) => e.id !== edgeId).map((e) => ({ ...e, selected: false })), - secondEdge, - ]); + data: { originalEdge }, // Pass original edge to restore if cancelled + sourceHandleType: 'source', // Source handle type + handlePosition: sourcePosition, + ignoredNodeTypes: ignoredNodeTypes ?? [], + targetNodeId: target, + targetHandleId, + removedEdgeIds: [edgeId], + }); }, [ sourcePosition, @@ -129,7 +110,7 @@ export function useEdgeToolbarState({ ); // Show toolbar when hovering, in design mode, have a valid mouse position, and not a preview edge - const showToolbar = isHovered && isDesignMode && positionData !== null && !isPreviewEdge; + const showToolbar = isHovered && isDesignMode && positionData !== null && !previewEdge; return { showToolbar, diff --git a/packages/apollo-react/src/canvas/components/Toolbar/NodeToolbar/NodeToolbar.tsx b/packages/apollo-react/src/canvas/components/Toolbar/NodeToolbar/NodeToolbar.tsx index 3a6a075d9..2eb57c450 100644 --- a/packages/apollo-react/src/canvas/components/Toolbar/NodeToolbar/NodeToolbar.tsx +++ b/packages/apollo-react/src/canvas/components/Toolbar/NodeToolbar/NodeToolbar.tsx @@ -3,6 +3,7 @@ import { AnimatePresence, motion } from 'motion/react'; import { memo, useMemo } from 'react'; import { CanvasIcon } from '../../../utils/icon-registry'; import { CanvasTooltip } from '../../CanvasTooltip'; +import { NodeViewportOverlay } from '../../NodeViewportOverlay'; import { ToolbarButton, ToolbarIconButton } from '../shared'; import type { NodeToolbarProps } from './NodeToolbar.types'; import { isSeparator } from './NodeToolbar.utils'; @@ -49,7 +50,7 @@ const SEPARATOR_HORIZONTAL_CLASS = 'w-full h-px'; const DROPDOWN_MENU_CLASS = 'absolute top-[-2px] left-[calc(100%+4px)] min-w-[180px] ' + 'bg-(--canvas-background-raised) border border-(--canvas-background-overlay) rounded-md ' + - 'shadow-[0_4px_12px_rgba(0,0,0,0.08),0_2px_4px_rgba(0,0,0,0.04)] p-1 z-[1000] pointer-events-auto'; + 'shadow-[0_4px_12px_rgba(0,0,0,0.08),0_2px_4px_rgba(0,0,0,0.04)] p-1 pointer-events-auto'; const DROPDOWN_ITEM_BASE_CLASS = 'flex items-center gap-3 w-full py-2 px-3 bg-transparent border-none rounded-[4px] ' + @@ -66,6 +67,7 @@ const NodeToolbarComponent = ({ expanded, hidden, offsetToolbar, + portalToNodeOverlay, }: NodeToolbarProps) => { const { isDropdownOpen, @@ -131,7 +133,7 @@ const NodeToolbarComponent = ({ return null; } - return ( + const toolbarContent = ( {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';