diff --git a/packages/apollo-react/src/canvas/README.md b/packages/apollo-react/src/canvas/README.md index 7395e635e..5802b72b4 100644 --- a/packages/apollo-react/src/canvas/README.md +++ b/packages/apollo-react/src/canvas/README.md @@ -195,3 +195,71 @@ If you already use `@uipath/apollo-react/canvas/styles/variables.css` with shado **Cause:** The Tailwind base layer includes `* { border-color: var(--color-border-de-emp) }` and `body { background-color: var(--background) }`. These may conflict with existing component styles. **Fix:** These rules are in `@layer base`, so they lose to un-layered styles. If conflicts persist in Shadow DOM, override with more specific selectors in your injected CSS. + +--- + +## Sequential view + +`SequentialCanvas` is an n8n/Zapier-style vertical projection of the same flow graph, built on the existing `BaseCanvas`. It reuses the same node manifests, icons, execution status, validation badges, and theming as the flow view; only the layout is different. A `ViewSwitcher` lets the host toggle a canvas between the free-form `flow` layout and the vertical `sequential` layout. + +### Projection model + +The consumer keeps one canonical `nodes`/`edges` array. `SequentialCanvas` derives vertical geometry from graph structure and never writes that geometry back. Entering Flow through `prepareCanvasViewTransition` computes a deterministic left-to-right layout and updates only canonical presentation fields (`position` and container dimensions); ids, data, containment, handles, and edges are unchanged. Nodes added in Sequential are normalized during the same transition. + +Sequential is not a symmetric conversion for every graph. `prepareCanvasViewTransition('sequential', ...)` returns a compatibility report: exact graphs are safely editable, degraded graphs (cycles, multiple roots, unstructured merges, orphans) should be presented read-only, and malformed containment is unsupported. Presentation-only nodes and edges can be excluded from the projection and preserved in the canonical graph. + +### Minimal usage + +```tsx +import { useState } from 'react'; +import { applyEdgeChanges, applyNodeChanges } from '@uipath/apollo-react/canvas/xyflow/react'; +import { + prepareCanvasViewTransition, + SequentialCanvas, + useCanvasViewMode, + ViewSwitcher, +} from '@uipath/apollo-react/canvas'; + +function MyFlow({ initialNodes, initialEdges }) { + const [view, setView] = useCanvasViewMode('my-flow.view'); + const [nodes, setNodes] = useState(initialNodes); + const [edges, setEdges] = useState(initialEdges); + + const changeView = (nextView) => { + const transition = prepareCanvasViewTransition(nextView, nodes, edges); + setNodes(transition.nodes); + setView(nextView); + }; + + return ( +
+ + setNodes((current) => applyNodeChanges(changes, current))} + onEdgesChange={(changes) => setEdges((current) => applyEdgeChanges(changes, current))} + /> +
+ ); +} +``` + +`SequentialCanvas` supplies its own `ReactFlowProvider` and keeps one `BaseCanvas` mounted while `view` changes. For a worked example, including per-view viewport save/restore, see `SequentialCanvasStoryHarness` and the `Wireframe` story. + +### Degraded graphs + +Not every graph is a clean, structured sequence. The projection degrades gracefully instead of failing: + +| Shape | Rendering | +|---|---| +| Multiple roots or disconnected components | Stacked lanes ordered by flow-view y | +| Unstructured merge (a node with more than one incoming edge) | Placed under its first incomer; the extra incoming edge draws a dashed goto connector | +| Cycles other than a loop's `loopBack` handle | A dashed, arrowless goto connector closes the cycle | +| Orphans (no sequence edges at all) | A de-emphasized trailing section after the terminal placeholder | + +Every case renders something rather than crashing or dropping a node: the cycle guard prevents infinite recursion, and unreachable or disconnected nodes are always appended as trailing rows. The toggle is never blocked. + +Both CSS delivery patterns described earlier in this guide (PostCSS scanning and precompiled Shadow DOM injection) cover the sequential view's markup with no extra configuration, since it is built entirely from Tailwind utility classes scanned from this package's `dist/canvas`. diff --git a/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.tsx b/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.tsx index 56aa2c457..212f9088c 100644 --- a/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.tsx +++ b/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.tsx @@ -2,7 +2,6 @@ import type { Node, NodeProps } from '@uipath/apollo-react/canvas/xyflow/react'; import { Position, useReactFlow, - useStore, useUpdateNodeInternals, } from '@uipath/apollo-react/canvas/xyflow/react'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -23,37 +22,21 @@ import { NODE_INNER_RADIUS_RATIO, NODE_INNER_SHAPE_RATIO, } from '../../constants'; -import { useNodeTypeRegistry } from '../../core'; -import { useElementValidationStatus, useNodeExecutionState } from '../../hooks'; import type { NodeShape } from '../../schema'; -import type { HandleGroupManifest } from '../../schema/node-definition'; -import { resolveAdornments } from '../../utils/adornment-resolver'; -import { CanvasIcon, getIcon } from '../../utils/icon-registry'; -import { resolveDisplay, resolveHandles } from '../../utils/manifest-resolver'; -import { selectIsConnecting } from '../../utils/NodeUtils'; +import { CanvasIcon } from '../../utils/icon-registry'; import { areNodePropsEqualIgnoringPosition } from '../../utils/nodePropsEqual'; -import { resolveToolbar } from '../../utils/toolbar-resolver'; -import { useBaseCanvasMode } from '../BaseCanvas/BaseCanvasModeProvider'; -import { useCanvasTheme } from '../BaseCanvas/CanvasThemeContext'; import { useConnectedHandles } from '../BaseCanvas/ConnectedHandlesContext'; -import { useSelectionState } from '../BaseCanvas/SelectionStateContext'; import type { HandleActionEvent } from '../ButtonHandle/ButtonHandle'; import { SmartHandle, SmartHandleProvider } from '../ButtonHandle/SmartHandle'; import { useButtonHandles } from '../ButtonHandle/useButtonHandles'; -import { InitialsBadge } from '../shared/InitialsBadge'; import { NodeToolbar } from '../Toolbar'; -import type { - BaseNodeData, - FooterVariant, - NodeAdornments, - NodeStatusContext, -} from './BaseNode.types'; +import type { BaseNodeData, FooterVariant } from './BaseNode.types'; import { BaseBadgeSlot } from './BaseNodeBadgeSlot'; -import { useBaseNodeOverrideConfig } from './BaseNodeConfigContext'; import { BaseContainer } from './BaseNodeContainer'; import { BaseInnerShape } from './BaseNodeInnerShape'; import { MissingManifestNode } from './BaseNodeMissingManifest'; import { NodeLabel } from './NodeLabel'; +import { useBaseNodePresentation } from './useBaseNodePresentation'; const getContainerWidth = (shape: NodeShape | undefined, width: number | undefined) => { const defaultWidth = shape === 'rectangle' ? DEFAULT_RECTANGLE_NODE_WIDTH : DEFAULT_NODE_SIZE; @@ -81,10 +64,24 @@ const getIntrinsicHeight = ( return NODE_HEIGHT_DEFAULT; }; -const BaseNodeComponent = (props: NodeProps>) => { - const { type, data, selected, id, dragging, width, parentId } = props; +export type BaseNodeProps = NodeProps>; - // Read runtime configuration from context (provided by parent node components) +const BaseNodeComponent = (props: BaseNodeProps) => { + const { type, data, selected, id, dragging, width, parentId } = props; + const presentation = useBaseNodePresentation({ type, data, selected, id, dragging }); + const { + adornments, + display, + executionStatus, + handleConfigurations, + icon: Icon, + manifest, + mode, + multipleNodesSelected, + onLabelChange: handleLabelChange, + toolbarConfig, + validationState, + } = presentation; const { onHandleAction: onHandleActionProp, onHandleMouseEnter: onHandleMouseEnterProp, @@ -92,22 +89,17 @@ const BaseNodeComponent = (props: NodeProps>) => { onActionNeeded, shouldShowAddButtonFn: shouldShowAddButtonFnProp, shouldShowButtonHandleNotchesFn: shouldShowButtonHandleNotchesFnProp, - toolbarConfig: toolbarConfigProp, - handleConfigurations: handleConfigurationsProp, - adornments: adornmentsProp, suggestionType, disabled, - executionStatusOverride, labelTooltip, labelBackgroundColor, footerVariant, footerComponent, subLabelComponent, - iconComponent, - } = useBaseNodeOverrideConfig(); + } = presentation.overrideConfig; const updateNodeInternals = useUpdateNodeInternals(); - const { updateNodeData, updateNode, getNode } = useReactFlow(); + const { updateNode, getNode } = useReactFlow(); const containerRef = useRef(null); const [isHovered, setIsHovered] = useState(false); const [isFocused, setIsFocused] = useState(false); @@ -115,138 +107,20 @@ const BaseNodeComponent = (props: NodeProps>) => { // Height is driven by computedHeight (below), a pure function of handle count/footer. // It never reads the measured height, so writing it back can't loop. - // Get execution status from external source - const executionState = useNodeExecutionState(id); - const validationState = useElementValidationStatus(id); - const nodeTypeRegistry = useNodeTypeRegistry(); - const { mode } = useBaseCanvasMode(); - // Use context for connected handles - O(1) lookup instead of O(edges) per node const connectedHandleIds = useConnectedHandles(id); - const isConnecting = useStore(selectIsConnecting); - const { multipleNodesSelected } = useSelectionState(); - - const { isDarkMode } = useCanvasTheme(); - - // Get manifest and resolve with instance data - const manifest = useMemo(() => nodeTypeRegistry.getManifest(type), [type, nodeTypeRegistry]); - - const statusContext: NodeStatusContext = useMemo( - () => ({ - nodeId: id, - executionState: executionStatusOverride ?? executionState, - validationState, - isConnecting, - isSelected: selected, - isDragging: dragging, - mode, - }), - [ - id, - executionStatusOverride, - executionState, - validationState, - isConnecting, - selected, - dragging, - mode, - ] - ); + const { isConnecting } = presentation; // Callbacks: Use props only (no longer in data) const onHandleActionCallback = onHandleActionProp; const shouldShowAddButtonFn = shouldShowAddButtonFnProp; const shouldShowButtonHandleNotchesFn = shouldShowButtonHandleNotchesFnProp; - // Use executionStatusOverride if provided, otherwise use hook value - const executionStatus = - executionStatusOverride ?? - (typeof executionState === 'string' ? executionState : executionState?.status); - - const display = useMemo( - () => resolveDisplay(manifest?.display, { ...data, nodeId: id }), - [manifest, data, id] - ); - // Drillable (manifest) or collapsed (instance data) nodes render a decorative // stacked layer to signal they stand in for more than themselves. const isStacked = Boolean(manifest?.drillable || data?.isCollapsed); - // Icon resolution: component prop > icon string > initials badge fallback. - const Icon = useMemo(() => { - if (iconComponent !== undefined) { - return iconComponent; - } - if (display.icon) { - const IconComponent = getIcon(display.icon); - return IconComponent ? : null; - } - return ; - }, [iconComponent, display.icon, display.label]); - - // Resolve handles: context override > data override > manifest default - const handleConfigurations = useMemo((): HandleGroupManifest[] => { - // Priority 1: Context override (runtime configuration from parent wrapper components) - if (handleConfigurationsProp && Array.isArray(handleConfigurationsProp)) { - return handleConfigurationsProp; - } - - // Priority 2: Per-instance override via node data - const dataHandleConfigs = (data as Record)?.handleConfigurations as - | HandleGroupManifest[] - | undefined; - if (dataHandleConfigs && Array.isArray(dataHandleConfigs)) { - return dataHandleConfigs; - } - - // Priority 3: Manifest default - if (!manifest) return []; - // Pass nodeId and collapsed for collapse state lookup - const resolved = resolveHandles(manifest.handleConfiguration, { ...data, nodeId: id }); - - // Convert resolved handles to HandleGroupManifest format for ButtonHandle - return resolved.map((group) => ({ - position: group.position, - handles: group.handles.map((h) => ({ - id: h.id, - type: h.type, - handleType: h.handleType, - label: h.label, - visible: h.visible, - showButton: h.showButton, - labelVisibility: h.labelVisibility, - constraints: h.constraints, - })), - visible: group.visible, - })); - }, [handleConfigurationsProp, manifest, data, id]); - - // Toolbar config resolution with priority: props > manifest - const toolbarConfig = useMemo(() => { - // Priority 1: Prop override (runtime callbacks) - // - undefined: use manifest default - // - null: explicitly no toolbar - // - object: use provided config - if (toolbarConfigProp !== undefined) { - return toolbarConfigProp === null ? undefined : toolbarConfigProp; - } - - // Priority 2: Manifest default - return manifest ? resolveToolbar(manifest, statusContext) : undefined; - }, [toolbarConfigProp, manifest, statusContext]); - - // Adornments resolution: use default resolver, then override with props if provided - const adornments: NodeAdornments = useMemo(() => { - const adornmentsFromProps = adornmentsProp ?? {}; - const adornmentsFromResolver = resolveAdornments(statusContext); - - return { - ...adornmentsFromResolver, - ...adornmentsFromProps, - }; - }, [adornmentsProp, statusContext]); - // Computed node height: max of the handle-count floor and the intrinsic default/footer // height. Pure (never reads the measured `height`); written to node.height below as // the authoritative size, so node content is expected to fit within it. @@ -289,9 +163,7 @@ const BaseNodeComponent = (props: NodeProps>) => { const displayBackground = display.background; const displayColor = display.color; const displayShadow = display.shadow ?? true; - const displayIconBackground = isDarkMode - ? (display.iconBackgroundDark ?? display.iconBackground) - : display.iconBackground; + const displayIconBackground = presentation.iconBackground; // Display customization from props (not data) const displayLabelTooltip = labelTooltip; @@ -401,24 +273,6 @@ const BaseNodeComponent = (props: NodeProps>) => { const handleFocus = useCallback(() => setIsFocused(true), []); const handleBlur = useCallback(() => setIsFocused(false), []); - const handleLabelChange = useCallback( - (values: { label: string; subLabel: string }) => { - const newDisplay = { ...data.display }; - // Ensure all label fields are updated or removed appropriately. - for (const labelKey of Object.keys(values) as (keyof typeof values)[]) { - if (values[labelKey]) { - newDisplay[labelKey] = values[labelKey]; - } else { - delete newDisplay[labelKey]; - } - } - updateNodeData(id, { - display: newDisplay, - }); - }, - [id, data.display, updateNodeData] - ); - // Calculate if notches should be shown (when node is hovered or selected) // Use custom function if provided, otherwise use default logic const showNotches = shouldShowButtonHandleNotchesFn @@ -464,7 +318,7 @@ const BaseNodeComponent = (props: NodeProps>) => { } } return { hasButton, hasLabel }; - }, [toolbarPosition, handleConfigurations]); + }, [toolbarPosition, handleConfigurations, useSmartHandles]); // Offset the toolbar to clear whichever handle affordance is actually rendered // at its side — not merely configured. A shown add button stacks button + label @@ -596,7 +450,6 @@ const BaseNodeComponent = (props: NodeProps>) => { if (!manifest) { return ( - // biome-ignore lint/a11y/noStaticElementInteractions: canvas node interaction
>) => { } const nodeContent = ( - // biome-ignore lint/a11y/noStaticElementInteractions: canvas node interaction
({ + mockUpdateNode: vi.fn(), + mockGetNode: vi.fn(), + mockNodeState: { current: { height: undefined as number | undefined } }, + mockHandleConfigs: { current: undefined as unknown }, + mockManifest: { + current: { + display: { label: 'HTTP Request', shape: 'rectangle', icon: 'globe' }, + handleConfiguration: [], + } as Record, + }, + mockOverrideConfig: { current: {} as Record }, + mockMode: { current: 'design' as string }, + // biome-ignore lint/suspicious/noExplicitAny: captures NodeLabel props for assertions + mockNodeLabel: vi.fn() as any, + mockSelectionState: { current: { multipleNodesSelected: false } }, +})); + +mockGetNode.mockImplementation(() => mockNodeState.current); +mockUpdateNode.mockImplementation((_id: string, patch: { height?: number }) => { + if (patch?.height !== undefined) { + mockNodeState.current = { ...mockNodeState.current, height: patch.height }; + } +}); + +vi.mock('@uipath/apollo-react/canvas/xyflow/react', async (importOriginal) => ({ + ...(await importOriginal()), + // Stub Handle so the bar can render without a React Flow zustand provider. + Handle: () => null, + useStore: () => false, + useUpdateNodeInternals: () => vi.fn(), + useReactFlow: () => ({ + updateNodeData: vi.fn(), + updateNode: mockUpdateNode, + getNode: mockGetNode, + }), +})); + +vi.mock('../../hooks', () => ({ + useNodeExecutionState: () => undefined, + useElementValidationStatus: () => undefined, +})); +vi.mock('../../core', () => ({ + useNodeTypeRegistry: () => ({ getManifest: () => mockManifest.current }), +})); +vi.mock('../BaseCanvas/BaseCanvasModeProvider', () => ({ + useBaseCanvasMode: () => ({ mode: mockMode.current }), +})); +vi.mock('../BaseCanvas/ConnectedHandlesContext', () => ({ useConnectedHandles: () => new Set() })); +vi.mock('../BaseCanvas/SelectionStateContext', () => ({ + useSelectionState: () => mockSelectionState.current, +})); +vi.mock('../BaseCanvas/CanvasThemeContext', () => ({ + useCanvasTheme: () => ({ isDarkMode: false }), +})); +vi.mock('../Toolbar', () => ({ NodeToolbar: () => null })); +vi.mock('../ButtonHandle/useButtonHandles', () => ({ useButtonHandles: () => null })); +vi.mock('../ButtonHandle/SmartHandle', () => ({ + SmartHandle: () => null, + SmartHandleProvider: ({ children }: { children: React.ReactNode }) => <>{children}, +})); +vi.mock('./BaseNodeConfigContext', () => ({ + useBaseNodeOverrideConfig: () => ({ + handleConfigurations: mockHandleConfigs.current, + ...mockOverrideConfig.current, + }), +})); +// A resolved trailing status indicator, so the ActionNeeded precedence is testable. +vi.mock('../../utils/adornment-resolver', () => ({ + resolveAdornments: () => ({ topRight: }), +})); +vi.mock('../../utils/toolbar-resolver', () => ({ resolveToolbar: () => undefined })); +vi.mock('../../../i18n', () => ({ + useSafeLingui: () => ({ + _: (d: string | { message?: string; id: string }) => + typeof d === 'string' ? d : (d.message ?? d.id), + }), +})); +vi.mock('@uipath/apollo-wind', () => ({ + Skeleton: (props: Record) =>
, + Button: ({ children, ...props }: Record & { children?: React.ReactNode }) => ( + + ), + DropdownMenu: ({ children }: { children: React.ReactNode }) => <>{children}, + DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <>{children}, + DropdownMenuItem: ({ + children, + onSelect, + disabled, + }: { + children: React.ReactNode; + onSelect?: () => void; + disabled?: boolean; + }) => ( + + ), + DropdownMenuSeparator: () =>
, + cn: (...args: unknown[]) => + args + .flat(Infinity) + .filter((v): v is string => typeof v === 'string' && v.length > 0) + .join(' '), +})); +vi.mock('../../utils/icon-registry', () => ({ + getIcon: () => () =>
Icon
, + CanvasIcon: ({ icon }: { icon: string }) => , +})); +vi.mock('../../utils/manifest-resolver', () => ({ + resolveDisplay: (display: Record | undefined) => ({ + label: 'HTTP Request', + subLabel: 'GET /orders', + shape: 'rectangle', + icon: 'globe', + iconBackground: '#611f69', + color: '#ffffff', + ...display, + }), + resolveHandles: () => [], +})); +vi.mock('./NodeLabel', () => ({ + NodeLabel: (props: { label?: string }) => { + mockNodeLabel(props); + return
{props.label}
; + }, +})); +vi.mock('./BaseNodeInnerShape', () => ({ + BaseInnerShape: ({ + color, + background, + children, + }: { + color?: string; + background?: string; + children: React.ReactNode; + }) => ( +
+ {children} +
+ ), +})); + +import { SEQ_HANDLE_LEFT_OFFSET } from '../../constants'; +import { BaseNode } from './BaseNode'; +import { INVISIBLE_HANDLE_STYLE } from './BaseNodeBar'; +import { BaseNodeBarNode } from './BaseNodeBarNode'; + +const defaultProps: NodeProps> = { + id: 'step-1', + type: 'uipath.http', + data: {}, + selected: false, + dragging: false, + draggable: true, + zIndex: 0, + isConnectable: true, + positionAbsoluteX: 0, + positionAbsoluteY: 0, + selectable: true, + deletable: true, +}; + +describe('BaseNodeBar (sequential bar variant)', () => { + afterEach(() => { + mockUpdateNode.mockClear(); + mockNodeState.current = { height: undefined }; + mockHandleConfigs.current = undefined; + mockOverrideConfig.current = {}; + mockMode.current = 'design'; + mockNodeLabel.mockClear(); + mockSelectionState.current = { multipleNodesSelected: false }; + mockManifest.current = { + display: { label: 'HTTP Request', shape: 'rectangle', icon: 'globe' }, + handleConfiguration: [], + }; + }); + + // The core visual-drift contract: the same node resolves to the same display + // (label / icon / color source) whether it renders as a card or a bar. + describe('display parity with the card variant', () => { + it('renders the identical resolved label and icon', () => { + const { unmount } = render(); + const cardLabel = screen.getByTestId('node-label').textContent; + expect(screen.getAllByTestId('node-icon').length).toBeGreaterThan(0); + unmount(); + + render(); + expect(screen.getByTestId('node-label').textContent).toBe(cardLabel); + expect(screen.getAllByTestId('node-icon').length).toBeGreaterThan(0); + }); + + it('feeds the icon shape from the same color source', () => { + const { unmount } = render(); + const cardShape = screen.getByTestId('inner-shape'); + const cardBg = cardShape.getAttribute('data-background'); + const cardColor = cardShape.getAttribute('data-color'); + unmount(); + + render(); + const barShape = screen.getByTestId('inner-shape'); + expect(barShape.getAttribute('data-background')).toBe(cardBg); + expect(barShape.getAttribute('data-color')).toBe(cardColor); + expect(cardBg).toBe('#611f69'); + }); + }); + + describe('bar geometry', () => { + it('renders at the fixed 896x56 bar size regardless of handle count', () => { + render(); + const bar = screen.getByTestId('sequential-bar'); + expect(bar.style.getPropertyValue('--node-w')).toBe('896px'); + expect(bar.style.getPropertyValue('--node-h')).toBe('56px'); + }); + + it('writes the fixed bar height (56) back to node.height', () => { + render(); + expect(mockUpdateNode).toHaveBeenCalledWith('step-1', { height: 56 }); + }); + }); + + it('paints the left accent strip from the resolved icon background', () => { + render(); + const accent = screen.getByTestId('sequential-bar-accent'); + // Rendered as an inset box-shadow so the stripe follows the bar's rounded + // corners rather than a square-cornered strip. + expect(accent.style.boxShadow).toContain('#611f69'); + }); + + it('keeps inline rename enabled in design mode (readonly=false, onChange wired)', () => { + render(); + const labelProps = mockNodeLabel.mock.calls.at(-1)?.[0]; + expect(labelProps.readonly).toBe(false); + expect(typeof labelProps.onChange).toBe('function'); + expect(labelProps.shape).toBe('rectangle'); + }); + + it('disables rename outside design mode', () => { + mockMode.current = 'view'; + render(); + expect(mockNodeLabel.mock.calls.at(-1)?.[0].readonly).toBe(true); + }); + + describe('trailing group', () => { + it('renders the toolbar actions as a kebab when selected', () => { + mockOverrideConfig.current = { + toolbarConfig: { + actions: [{ id: 'delete', icon: , label: 'Delete', onAction: vi.fn() }], + }, + }; + render(); + expect(screen.getByLabelText('More options')).toBeInTheDocument(); + }); + + it('fires the toolbar action with the node id when the item is selected', () => { + const onAction = vi.fn(); + mockOverrideConfig.current = { + toolbarConfig: { + actions: [{ id: 'delete', icon: , label: 'Delete', onAction }], + }, + }; + render(); + fireEvent.click(screen.getByText('Delete')); + expect(onAction).toHaveBeenCalledWith('step-1'); + }); + + it('resolves a string toolbar icon through the icon registry instead of rendering it as literal text', () => { + mockOverrideConfig.current = { + toolbarConfig: { + actions: [{ id: 'delete', icon: 'trash', label: 'Delete', onAction: vi.fn() }], + }, + }; + render(); + expect(screen.getByTestId('canvas-icon-trash')).toBeInTheDocument(); + expect(screen.queryByText('trash')).not.toBeInTheDocument(); + }); + + it('still renders a custom React node icon as-is (no double resolution)', () => { + mockOverrideConfig.current = { + toolbarConfig: { + actions: [ + { + id: 'delete', + icon: , + label: 'Delete', + onAction: vi.fn(), + }, + ], + }, + }; + render(); + expect(screen.getByTestId('custom-icon')).toBeInTheDocument(); + }); + + it('hides the kebab when multiple nodes are selected, mirroring the card toolbar gating', () => { + mockOverrideConfig.current = { + toolbarConfig: { + actions: [{ id: 'delete', icon: , label: 'Delete', onAction: vi.fn() }], + }, + }; + mockSelectionState.current = { multipleNodesSelected: true }; + render(); + expect(screen.queryByLabelText('More options')).not.toBeInTheDocument(); + }); + + it('shows the status indicator when not action-needed', () => { + render(); + expect(screen.getByTestId('status-indicator')).toBeInTheDocument(); + expect(screen.queryByText('Action needed')).not.toBeInTheDocument(); + }); + + it('gives the Action needed pill precedence over the status indicator', () => { + const onActionNeeded = vi.fn(); + mockOverrideConfig.current = { executionStatusOverride: 'ActionNeeded', onActionNeeded }; + render(); + + expect(screen.queryByTestId('status-indicator')).not.toBeInTheDocument(); + const pill = screen.getByText('Action needed'); + fireEvent.click(pill); + expect(onActionNeeded).toHaveBeenCalledWith('step-1'); + }); + }); + + describe('extraMenuItems passthrough', () => { + it('shows the kebab from extraMenuItems alone, with no toolbar configured', () => { + render( + + ); + expect(screen.getByLabelText('More options')).toBeInTheDocument(); + expect(screen.getByText('Move up')).toBeInTheDocument(); + }); + + it('appends extraMenuItems after the toolbar actions with a divider between the two groups', () => { + mockOverrideConfig.current = { + toolbarConfig: { + actions: [{ id: 'delete', icon: , label: 'Delete', onAction: vi.fn() }], + }, + }; + const { container } = render( + + ); + expect(screen.getByText('Delete')).toBeInTheDocument(); + expect(screen.getByText('Move up')).toBeInTheDocument(); + // DropdownMenuSeparator is mocked to
(see the @uipath/apollo-wind mock above). + expect(container.querySelectorAll('hr')).toHaveLength(1); + }); + + it('fires an extraMenuItems action on click', () => { + const onClick = vi.fn(); + render( + + ); + fireEvent.click(screen.getByText('Move up')); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('renders a disabled extraMenuItems action as disabled', () => { + render( + + ); + expect(screen.getByText('Move up').closest('button')).toBeDisabled(); + }); + + it('renders no kebab at all when there is neither a toolbar nor extraMenuItems', () => { + render(); + expect(screen.queryByLabelText('More options')).not.toBeInTheDocument(); + }); + }); + + describe('display parity: background and shadow', () => { + it('applies display.background as an inline background style, like BaseContainer', () => { + mockManifest.current = { + display: { + label: 'HTTP Request', + shape: 'rectangle', + icon: 'globe', + background: 'linear-gradient(90deg, #611f69, #0ea5e9)', + }, + handleConfiguration: [], + }; + render(); + const bar = screen.getByTestId('sequential-bar'); + expect(bar.style.background).toContain('linear-gradient'); + }); + + it('renders the rest shadow class by default (display.shadow unset)', () => { + render(); + const bar = screen.getByTestId('sequential-bar'); + expect(bar.className).toContain('shadow-(--canvas-node-shadow-rest)'); + }); + + it('omits the rest shadow class when display.shadow is false, like BaseContainer', () => { + mockManifest.current = { + display: { label: 'HTTP Request', shape: 'rectangle', icon: 'globe', shadow: false }, + handleConfiguration: [], + }; + render(); + const bar = screen.getByTestId('sequential-bar'); + expect(bar.className).not.toContain('shadow-(--canvas-node-shadow-rest)'); + }); + }); + + describe('loading accessibility', () => { + it('sets aria-busy on the bar shell while loading, matching BaseContainer', () => { + render(); + const bar = screen.getByTestId('sequential-bar'); + expect(bar).toHaveAttribute('aria-busy', 'true'); + }); + + it('omits aria-busy when not loading', () => { + render(); + const bar = screen.getByTestId('sequential-bar'); + expect(bar).not.toHaveAttribute('aria-busy'); + }); + }); + + // Connectors anchor to the bar's bottom-left / top-left + // region instead of its horizontal center. `Handle` itself is stubbed to + // `null` above (BaseNode's Handle-rendering internals aren't under test + // here), so this asserts directly against the shared style object BOTH the + // top target and bottom source handles use -- the actual source of truth + // xyflow reads to position the real handle DOM nodes in production. + describe('handle anchoring', () => { + it('anchors the invisible top/bottom handles at SEQ_HANDLE_LEFT_OFFSET from the bar left edge, not the horizontal center', () => { + expect(INVISIBLE_HANDLE_STYLE.left).toBe(SEQ_HANDLE_LEFT_OFFSET); + }); + }); + + // A collapsed collapsible sequential row renders the same + // decorative "stacked" treatment BaseContainer applies to drillable/collapsed + // cards (a layered bar peeking out behind), driven by a `stacked` prop + // (threaded via context by SequentialStepNode, never node.data) rather than + // any state BaseNodeBar derives itself. + describe('collapsed stacked treatment', () => { + it('renders no stacked-layer classes or data-stacked attribute by default', () => { + render(); + const bar = screen.getByTestId('sequential-bar'); + expect(bar).not.toHaveAttribute('data-stacked'); + expect(bar.className).not.toContain('before:border-brand'); + }); + + it('renders the stacked-layer classes and data-stacked when stacked=true', () => { + render(); + const bar = screen.getByTestId('sequential-bar'); + expect(bar).toHaveAttribute('data-stacked', 'true'); + expect(bar.className).toContain('before:border-brand'); + expect(bar.className).toContain('before:-z-10'); + expect(bar.className).toContain('before:translate-y-[6px]'); + }); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/BaseNode/BaseNodeBar.tsx b/packages/apollo-react/src/canvas/components/BaseNode/BaseNodeBar.tsx new file mode 100644 index 000000000..490747d7b --- /dev/null +++ b/packages/apollo-react/src/canvas/components/BaseNode/BaseNodeBar.tsx @@ -0,0 +1,390 @@ +import { Handle, Position } from '@uipath/apollo-react/canvas/xyflow/react'; +import { cn } from '@uipath/apollo-wind'; +import { memo, useMemo, useState } from 'react'; +import { useSafeLingui } from '../../../i18n'; +import { SEQ_BAR_HEIGHT, SEQ_BAR_WIDTH, SEQ_HANDLE_LEFT_OFFSET } from '../../constants'; +import type { SuggestionType } from '../../types'; +import type { ElementStatusValues } from '../../types/execution'; +import type { ValidationErrorSeverity } from '../../types/validation'; +import { CanvasIcon } from '../../utils/icon-registry'; +import type { NodeMenuItem } from '../NodeContextMenu'; +import { CanvasDropdownMenu } from '../shared/CanvasDropdownMenu'; +import type { NodeToolbarConfig } from '../Toolbar'; +import { getStatusBorder } from './BaseNodeContainer'; +import { BaseInnerShape } from './BaseNodeInnerShape'; +import { NodeLabel, type NodeLabelProps } from './NodeLabel'; + +/** + * Bar geometry as CSS custom properties, shared by the manifest-backed step bar + * (BaseNodeBar) and the synthetic start / placeholder bars so all three render + * at an identical size. Fixed values (not the card's ratio math) because a + * compact row needs a constant icon box rather than one that scales with the + * container aspect ratio. Icon box shrunk to ~32px (from 40px) to stay + * balanced against the tightened `SEQ_BAR_HEIGHT`; the + * icon-to-box and radius-to-box ratios are unchanged from the original. + */ +export const SEQ_BAR_VARS = { + '--node-w': `${SEQ_BAR_WIDTH}px`, + '--node-h': `${SEQ_BAR_HEIGHT}px`, + '--node-radius': '12px', + '--inner-w': '32px', + '--inner-h': '32px', + '--inner-radius': '8px', + '--icon-size': '16px', +} as React.CSSProperties; + +/** Returns the shared bar CSS variables with optional layout-owned dimensions. */ +export function getSeqBarVars(width = SEQ_BAR_WIDTH, height = SEQ_BAR_HEIGHT): React.CSSProperties { + return { + ...SEQ_BAR_VARS, + '--node-w': `${width}px`, + '--node-h': `${height}px`, + } as React.CSSProperties; +} + +/** + * Static base classes for the bar shell. Mirrors BaseContainer's channel + * discipline (surface + border) in a horizontal layout. The rest/hover/lifted + * shadow classes are layered on per instance (gated by the `shadow` prop, like + * BaseContainer), along with status border, hover, and selection classes. + * Padding/gap tightened (`px-4`/`gap-3` -> `px-3`/`gap-2`) alongside the + * shorter `SEQ_BAR_HEIGHT` so the row reads balanced rather than cramped. + */ +export const SEQ_BAR_SHELL_CLASS = + 'relative flex flex-row items-center gap-2 px-3 bg-surface-overlay border border-border w-(--node-w) h-(--node-h) rounded-(--node-radius) outline-offset-0 transition-[box-shadow,border-color] duration-150'; + +/** + * Handle ids used by every sequential bar. Rows expose one invisible top target + * and one invisible bottom source; connectors and the assembly wire + * edges between `source` of row N and `target` of row N+1. + */ +export const SEQUENTIAL_BAR_HANDLE_IDS = { + target: 'seq-target', + source: 'seq-source', + // Mid-left target used only by `branch-entry` connectors: a branch/container + // lane's first row is entered from its LEFT side at mid-height (not the top), + // so the elbow off the owner's spine reads as an indent. Left handles sit at + // the bar's left edge, vertically centered, with no left-offset. + branchTarget: 'seq-branch-target', +} as const; + +/** + * Transparent 8px handle: sequential bars are not user-connectable in v1. + * `left` is pinned to `SEQ_HANDLE_LEFT_OFFSET` so the handle anchors to the + * bar's bottom-left / top-left region instead of xyflow's + * default horizontal center; the vertical placement and centering transform + * still come from xyflow's `.react-flow__handle-{top,bottom}` classes; this + * inline style only overrides `left`, not `transform`, so the handle's CENTER + * point lands exactly `SEQ_HANDLE_LEFT_OFFSET`px from the bar's left edge. + * Shared by the manifest-backed bar (below) and the synthetic start/placeholder + * bars (nodes/SequentialStartNode.tsx, nodes/SequentialPlaceholderNode.tsx) so + * every row -- real or synthetic -- anchors its connectors identically. + */ +export const INVISIBLE_HANDLE_STYLE: React.CSSProperties = { + background: 'transparent', + border: 'none', + width: 8, + height: 8, + left: SEQ_HANDLE_LEFT_OFFSET, +}; + +/** + * Decorative stacked layer for a collapsed sequential row, mirroring + * BaseContainer's `isStacked` recipe (components/BaseNode/BaseNodeContainer.tsx) + * adapted to the bar's horizontal shape: a `::before` pseudo-element the same + * size as the bar, offset down and pushed behind it (`-z-10`), so a thin arc of + * a second bar peeks out below -- signalling that collapsed content sits + * underneath. Kept as a local copy (not a shared export) so this file's owner + * doesn't need to also own BaseNodeContainer.tsx; keep the two class strings in + * sync if BaseContainer's recipe changes. + */ +const SEQ_BAR_STACKED_LAYER_CLASS = + 'before:content-[""] before:absolute before:inset-x-0 before:top-0 before:h-full before:rounded-[inherit] before:bg-surface-overlay before:border before:border-brand before:translate-y-[6px] before:-z-10 before:pointer-events-none'; + +export interface BaseNodeBarProps { + nodeId: string; + width?: number; + height?: number; + mode?: 'design' | 'view' | 'readonly'; + selected?: boolean; + dragging?: boolean; + disabled?: boolean; + label?: string; + subLabel?: NodeLabelProps['subLabel']; + labelTooltip?: string; + labelBackgroundColor?: string; + icon: React.ReactNode; + loading?: boolean; + /** Drives both the icon-box background and the left accent strip. */ + iconBackground?: string; + iconColor?: string; + /** Same resolved display.background the card applies via BaseContainer. */ + background?: string; + /** Same resolved display.shadow the card applies via BaseContainer. Defaults to true. */ + shadow?: boolean; + executionStatus?: ElementStatusValues; + validationStatus?: ValidationErrorSeverity; + suggestionType?: SuggestionType; + /** The resolved trailing status/validation indicator (adornments.topRight). */ + statusIndicator?: React.ReactNode; + /** Same resolved toolbar as the card; its actions feed the kebab menu (D3). */ + toolbarConfig?: NodeToolbarConfig; + /** Hides the kebab during rubber-band multi-select, mirroring the card toolbar (BaseNode.tsx `hidden`). */ + multipleNodesSelected?: boolean; + /** + * True when this row is a collapsed collapsible step. Driven by + * view-local collapsed state threaded through context (SequentialStepNode -> + * SequentialCollapsedRowsContext), never `node.data`, so the sequential + * clone's data stays reference-stable (D12). Renders the same decorative + * stacked-layer treatment BaseContainer uses for `isStacked` cards. + */ + stacked?: boolean; + /** + * Extra kebab items appended after the resolved toolbar actions, with + * a divider between the two groups when both are non-empty. See + * `BaseNodeBarNodeProps.extraMenuItems`'s doc comment (BaseNodeBarNode.tsx) for the D3 + * rationale (a direct prop, not a new `BaseNodeOverrideConfig` field). + */ + extraMenuItems?: NodeMenuItem[]; + /** + * Manifest source/target handle ids to expose as extra INVISIBLE anchor + * handles alongside the generic seq-source/seq-target. A bar collapses + * each side to one point visually, but the Add Node connection validator + * resolves a node's handle by id against its manifest, so the insert preview + * must be able to anchor on a real manifest handle id that the bar actually + * renders. Derived connectors keep using seq-source/seq-target. + */ + manifestSourceHandleIds?: string[]; + manifestTargetHandleIds?: string[]; + onLabelChange?: (values: { label: string; subLabel: string }) => void; + onActionNeeded?: (nodeId: string) => void; +} + +/** + * Horizontal "bar" rendering of a node for the Sequential Canvas view. Fed by + * the same resolved display / adornments / toolbar sources as BaseNode through + * useBaseNodePresentation, so a node looks and behaves identically in both + * views. Card geometry and interactions remain owned by BaseNode. + */ +function BaseNodeBarComponent({ + nodeId, + width, + height, + mode, + selected, + dragging, + disabled, + label, + subLabel, + labelTooltip, + labelBackgroundColor, + icon, + loading, + iconBackground, + iconColor, + background, + shadow = true, + executionStatus, + validationStatus, + suggestionType, + statusIndicator, + toolbarConfig, + multipleNodesSelected, + stacked, + extraMenuItems, + manifestSourceHandleIds, + manifestTargetHandleIds, + onLabelChange, + onActionNeeded, +}: BaseNodeBarProps) { + const { _ } = useSafeLingui(); + const [isHovered, setIsHovered] = useState(false); + const [menuOpen, setMenuOpen] = useState(false); + + // Same priority as BaseContainer: suggestion > validation > execution. The + // status border stays the border channel; selection stays the outline channel. + const activeStatus = suggestionType ?? validationStatus ?? executionStatus; + const statusBorder = getStatusBorder(activeStatus); + const hasStatusBorder = statusBorder.length > 0; + + const isActionNeeded = executionStatus === 'ActionNeeded'; + + // Kebab items are the resolved toolbar actions (design-mode delete/duplicate/ + // etc.), flattened from the same NodeToolbarConfig the card toolbar consumes, + // with the extraMenuItems (Sequential Canvas move actions) appended after + // a divider when both groups are present. + const menuItems = useMemo(() => { + const toolbarItems: NodeMenuItem[] = !toolbarConfig + ? [] + : [...toolbarConfig.actions, ...(toolbarConfig.overflowActions ?? [])].map( + (action) => { + if (!('onAction' in action)) { + return { type: 'divider' }; + } + return { + id: action.id, + label: action.label ?? action.id, + icon: + typeof action.icon === 'string' ? ( + + ) : ( + action.icon + ), + disabled: action.disabled, + onClick: () => action.onAction(nodeId), + }; + } + ); + + if (!extraMenuItems || extraMenuItems.length === 0) return toolbarItems; + if (toolbarItems.length === 0) return extraMenuItems; + return [...toolbarItems, { type: 'divider' }, ...extraMenuItems]; + }, [toolbarConfig, nodeId, extraMenuItems]); + + const showKebab = + menuItems.length > 0 && !multipleNodesSelected && (isHovered || selected || menuOpen); + + const className = cn( + SEQ_BAR_SHELL_CLASS, + 'cursor-pointer', + shadow && 'shadow-(--canvas-node-shadow-rest)', + statusBorder, + shadow && isHovered && 'shadow-(--canvas-node-shadow-hover)', + isHovered && !hasStatusBorder && 'border-border-hover', + selected && 'outline outline-2 outline-foreground-accent-muted', + disabled && 'opacity-50 cursor-not-allowed', + dragging && cn('cursor-grabbing', shadow && 'shadow-(--canvas-node-shadow-lifted)'), + stacked && SEQ_BAR_STACKED_LAYER_CLASS + ); + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + + {/* Mid-left entry for branch-entry connectors (no left-offset: a Left + handle already anchors at the bar's left edge, vertically centered). */} + + {manifestTargetHandleIds?.map((hid) => + hid === SEQUENTIAL_BAR_HANDLE_IDS.target ? null : ( + + ) + )} + + {iconBackground && ( + // Left accent stripe painted as an inset box-shadow on a bar-sized + // overlay so it follows the bar's rounded corners exactly. A plain + // `w-1` strip can't carry a matching corner radius (its 4px width clamps + // the curve), so its square corners poked past the rounded bar edge. + // `-inset-px` grows the overlay to the border-box edge (the overlay + // paints on top of the border), so the stripe sits flush against the + // selection outline instead of leaving the 1px border as a dark gap. + + )} + + {(icon || loading) && ( +
+ + {loading ? null : icon} + +
+ )} + + + +
+ {isActionNeeded ? ( + + ) : ( + statusIndicator + )} + {showKebab && ( + item.onClick()} + triggerAriaLabel={_({ id: 'sequential-canvas.more-options', message: 'More options' })} + /> + )} +
+ + + {manifestSourceHandleIds?.map((hid) => + hid === SEQUENTIAL_BAR_HANDLE_IDS.source ? null : ( + + ) + )} +
+ ); +} + +export const BaseNodeBar = memo(BaseNodeBarComponent); diff --git a/packages/apollo-react/src/canvas/components/BaseNode/BaseNodeBarNode.tsx b/packages/apollo-react/src/canvas/components/BaseNode/BaseNodeBarNode.tsx new file mode 100644 index 000000000..453d535d8 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/BaseNode/BaseNodeBarNode.tsx @@ -0,0 +1,122 @@ +import type { Node, NodeProps } from '@uipath/apollo-react/canvas/xyflow/react'; +import { useReactFlow, useUpdateNodeInternals } from '@uipath/apollo-react/canvas/xyflow/react'; +import { memo, useEffect, useMemo } from 'react'; +import { + DEFAULT_NODE_SIZE, + DEFAULT_RECTANGLE_NODE_WIDTH, + SEQ_BAR_HEIGHT, + SEQ_BAR_WIDTH, +} from '../../constants'; +import { areNodePropsEqualIgnoringPosition } from '../../utils/nodePropsEqual'; +import type { NodeMenuItem } from '../NodeContextMenu'; +import type { BaseNodeData } from './BaseNode.types'; +import { BaseNodeBar } from './BaseNodeBar'; +import { MissingManifestNode } from './BaseNodeMissingManifest'; +import { useBaseNodePresentation } from './useBaseNodePresentation'; + +export type BaseNodeBarNodeProps = NodeProps> & { + stacked?: boolean; + extraMenuItems?: NodeMenuItem[]; +}; + +function BaseNodeBarNodeComponent({ + id, + data, + type, + selected, + dragging, + width, + height, + stacked, + extraMenuItems, +}: BaseNodeBarNodeProps) { + const presentation = useBaseNodePresentation({ + id, + data, + type, + selected, + dragging, + }); + const { updateNode, getNode } = useReactFlow(); + const updateNodeInternals = useUpdateNodeInternals(); + const barHeight = height ?? SEQ_BAR_HEIGHT; + const barWidth = + width && width !== DEFAULT_NODE_SIZE && width !== DEFAULT_RECTANGLE_NODE_WIDTH + ? width + : SEQ_BAR_WIDTH; + const manifestHandleIds = useMemo(() => { + const sources: string[] = []; + const targets: string[] = []; + for (const group of presentation.handleConfigurations) { + for (const handle of group.handles) { + if (handle.type === 'source') sources.push(handle.id); + else if (handle.type === 'target') targets.push(handle.id); + } + } + return { sources, targets }; + }, [presentation.handleConfigurations]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: handle configuration changes require XYFlow to recalculate the bar's invisible anchors. + useEffect(() => { + if (getNode(id)?.height !== barHeight) { + updateNode(id, { height: barHeight }); + } + updateNodeInternals(id); + }, [barHeight, id, presentation.handleConfigurations, getNode, updateNode, updateNodeInternals]); + + if (!presentation.manifest) { + return ( + + ); + } + + const { + disabled, + labelBackgroundColor, + labelTooltip, + onActionNeeded, + subLabelComponent, + suggestionType, + } = presentation.overrideConfig; + + return ( + + ); +} + +export const BaseNodeBarNode = memo(BaseNodeBarNodeComponent, areNodePropsEqualIgnoringPosition); diff --git a/packages/apollo-react/src/canvas/components/BaseNode/index.ts b/packages/apollo-react/src/canvas/components/BaseNode/index.ts index 76069fd0b..7e37289b7 100644 --- a/packages/apollo-react/src/canvas/components/BaseNode/index.ts +++ b/packages/apollo-react/src/canvas/components/BaseNode/index.ts @@ -1,4 +1,5 @@ export * from './BaseNode'; export * from './BaseNode.types'; +export * from './BaseNodeBarNode'; export * from './BaseNodeConfigContext'; export * from './useNodeCollapse'; diff --git a/packages/apollo-react/src/canvas/components/BaseNode/useBaseNodePresentation.tsx b/packages/apollo-react/src/canvas/components/BaseNode/useBaseNodePresentation.tsx new file mode 100644 index 000000000..8e866de16 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/BaseNode/useBaseNodePresentation.tsx @@ -0,0 +1,159 @@ +import type { Node, NodeProps } from '@uipath/apollo-react/canvas/xyflow/react'; +import { useReactFlow, useStore } from '@uipath/apollo-react/canvas/xyflow/react'; +import { useCallback, useMemo } from 'react'; +import { useNodeTypeRegistry } from '../../core'; +import { useElementValidationStatus, useNodeExecutionState } from '../../hooks'; +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'; +import { useSelectionState } from '../BaseCanvas/SelectionStateContext'; +import { InitialsBadge } from '../shared/InitialsBadge'; +import type { BaseNodeData, NodeAdornments, NodeStatusContext } from './BaseNode.types'; +import { useBaseNodeOverrideConfig } from './BaseNodeConfigContext'; + +type PresentationNodeProps = Pick< + NodeProps>, + 'data' | 'dragging' | 'id' | 'selected' | 'type' +>; + +/** + * Manifest-backed presentation model shared by the card and sequential bar + * node renderers. View selection stays outside this hook: both renderers + * resolve the same display, icon, handles, toolbar, adornments, and statuses, + * then render their own geometry and interactions. + */ +export function useBaseNodePresentation({ + type, + data, + selected, + id, + dragging, +}: PresentationNodeProps) { + const overrideConfig = useBaseNodeOverrideConfig(); + const { + executionStatusOverride, + handleConfigurations: handleConfigurationsOverride, + toolbarConfig: toolbarConfigOverride, + adornments: adornmentsOverride, + iconComponent, + } = overrideConfig; + const executionState = useNodeExecutionState(id); + const validationState = useElementValidationStatus(id); + const nodeTypeRegistry = useNodeTypeRegistry(); + const { mode } = useBaseCanvasMode(); + const isConnecting = useStore(selectIsConnecting); + const { multipleNodesSelected } = useSelectionState(); + const { isDarkMode } = useCanvasTheme(); + const { updateNodeData } = useReactFlow(); + + const manifest = useMemo(() => nodeTypeRegistry.getManifest(type), [type, nodeTypeRegistry]); + const statusContext: NodeStatusContext = useMemo( + () => ({ + nodeId: id, + executionState: executionStatusOverride ?? executionState, + validationState, + isConnecting, + isSelected: selected, + isDragging: dragging, + mode, + }), + [ + id, + executionStatusOverride, + executionState, + validationState, + isConnecting, + selected, + dragging, + mode, + ] + ); + const executionStatus = + executionStatusOverride ?? + (typeof executionState === 'string' ? executionState : executionState?.status); + const display = useMemo( + () => resolveDisplay(manifest?.display, { ...data, nodeId: id }), + [manifest, data, id] + ); + const icon = useMemo(() => { + if (iconComponent !== undefined) return iconComponent; + if (display.icon) { + const IconComponent = getIcon(display.icon); + return IconComponent ? : null; + } + return ; + }, [iconComponent, display.icon, display.label]); + const handleConfigurations = useMemo((): HandleGroupManifest[] => { + if (handleConfigurationsOverride && Array.isArray(handleConfigurationsOverride)) { + return handleConfigurationsOverride; + } + const dataHandleConfigs = (data as Record)?.handleConfigurations as + | HandleGroupManifest[] + | undefined; + if (dataHandleConfigs && Array.isArray(dataHandleConfigs)) return dataHandleConfigs; + if (!manifest) return []; + return resolveHandles(manifest.handleConfiguration, { ...data, nodeId: id }).map((group) => ({ + position: group.position, + handles: group.handles.map((handle) => ({ + id: handle.id, + type: handle.type, + handleType: handle.handleType, + label: handle.label, + visible: handle.visible, + showButton: handle.showButton, + labelVisibility: handle.labelVisibility, + constraints: handle.constraints, + })), + visible: group.visible, + })); + }, [handleConfigurationsOverride, manifest, data, id]); + const toolbarConfig = useMemo(() => { + if (toolbarConfigOverride !== undefined) { + return toolbarConfigOverride === null ? undefined : toolbarConfigOverride; + } + return manifest ? resolveToolbar(manifest, statusContext) : undefined; + }, [toolbarConfigOverride, manifest, statusContext]); + const adornments: NodeAdornments = useMemo( + () => ({ + ...resolveAdornments(statusContext), + ...(adornmentsOverride ?? {}), + }), + [adornmentsOverride, statusContext] + ); + const onLabelChange = useCallback( + (values: { label: string; subLabel: string }) => { + const nextDisplay = { ...data.display }; + for (const labelKey of Object.keys(values) as (keyof typeof values)[]) { + if (values[labelKey]) nextDisplay[labelKey] = values[labelKey]; + else delete nextDisplay[labelKey]; + } + updateNodeData(id, { display: nextDisplay }); + }, + [id, data.display, updateNodeData] + ); + const iconBackground = isDarkMode + ? (display.iconBackgroundDark ?? display.iconBackground) + : display.iconBackground; + + return { + adornments, + display, + executionStatus, + handleConfigurations, + icon, + iconBackground, + isConnecting, + manifest, + mode, + multipleNodesSelected, + onLabelChange, + overrideConfig, + toolbarConfig, + validationState, + }; +} diff --git a/packages/apollo-react/src/canvas/components/Edges/shared/hooks/useEdgeGeometry.ts b/packages/apollo-react/src/canvas/components/Edges/shared/hooks/useEdgeGeometry.ts index 4c6b3c985..733c0978d 100644 --- a/packages/apollo-react/src/canvas/components/Edges/shared/hooks/useEdgeGeometry.ts +++ b/packages/apollo-react/src/canvas/components/Edges/shared/hooks/useEdgeGeometry.ts @@ -50,6 +50,13 @@ export type UseEdgeGeometryArgs = { * arrow polygon would have filled. */ hideArrowHead?: boolean; + /** + * Corner radius (px) for the rounded (smooth-step) waypoint path. Defaults to + * `EDGE_CONSTANTS.BORDER_RADIUS`. `createRoundedPath` clamps it to half the + * shorter adjacent segment, so an over-large value degrades gracefully on + * short segments. Only affects `waypoint` routing. + */ + borderRadius?: number; }; export type EdgeGeometry = { @@ -93,6 +100,7 @@ export function useEdgeGeometry(args: UseEdgeGeometryArgs): EdgeGeometry { autoRouted = false, enableSegments = true, hideArrowHead = false, + borderRadius = EDGE_CONSTANTS.BORDER_RADIUS, } = args; const arrowOffset = ARROW_OFFSETS[targetPosition]; @@ -132,8 +140,8 @@ export function useEdgeGeometry(args: UseEdgeGeometryArgs): EdgeGeometry { ); const waypointPath = useMemo( - () => createRoundedPath(pathPoints, EDGE_CONSTANTS.BORDER_RADIUS), - [pathPoints] + () => createRoundedPath(pathPoints, borderRadius), + [pathPoints, borderRadius] ); const handle = useEdgePath({ diff --git a/packages/apollo-react/src/canvas/components/Edges/shared/primitives/EdgeLabel.tsx b/packages/apollo-react/src/canvas/components/Edges/shared/primitives/EdgeLabel.tsx index 10bc89106..3a08ccce2 100644 --- a/packages/apollo-react/src/canvas/components/Edges/shared/primitives/EdgeLabel.tsx +++ b/packages/apollo-react/src/canvas/components/Edges/shared/primitives/EdgeLabel.tsx @@ -5,6 +5,34 @@ export type EdgeLabelProps = { selected?: boolean; }; +export type EdgeLabelContentProps = Pick & { + centered?: boolean; +}; + +/** Shared visual treatment for SVG and portal-positioned edge labels. */ +export function EdgeLabelContent({ text, selected, centered }: EdgeLabelContentProps) { + return ( +
+ {text} +
+ ); +} + export function EdgeLabel({ x, y, text, selected }: EdgeLabelProps) { return ( -
- {text} -
+
); } diff --git a/packages/apollo-react/src/canvas/components/Edges/shared/primitives/index.ts b/packages/apollo-react/src/canvas/components/Edges/shared/primitives/index.ts index ed4d258c1..0837be3f3 100644 --- a/packages/apollo-react/src/canvas/components/Edges/shared/primitives/index.ts +++ b/packages/apollo-react/src/canvas/components/Edges/shared/primitives/index.ts @@ -1,7 +1,7 @@ export type { EdgeArrowProps } from './EdgeArrow'; export { EdgeArrow } from './EdgeArrow'; -export type { EdgeLabelProps } from './EdgeLabel'; -export { EdgeLabel } from './EdgeLabel'; +export type { EdgeLabelContentProps, EdgeLabelProps } from './EdgeLabel'; +export { EdgeLabel, EdgeLabelContent } from './EdgeLabel'; export type { EdgePathProps } from './EdgePath'; export { EdgePath } from './EdgePath'; export type { SegmentDragHandleProps } from './SegmentDragHandle'; diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialAccessibleList.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialAccessibleList.tsx new file mode 100644 index 000000000..ba1563d7c --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialAccessibleList.tsx @@ -0,0 +1,117 @@ +import type { Node } from '@uipath/apollo-react/canvas/xyflow/react'; +import { memo, useCallback, useMemo, useRef } from 'react'; +import { useSafeLingui } from '../../../i18n'; +import type { SequentialKeyboardRow } from './useSequentialKeyboard'; + +interface AccessibleRow extends SequentialKeyboardRow { + stepNumber?: number; +} + +export interface SequentialAccessibleListProps { + rows: readonly AccessibleRow[]; + nodes: readonly Node[]; + selectedNodeId?: string; + onSelectNode: (nodeId: string) => void; + onToggleCollapse: (nodeId: string, collapsed: boolean) => void; +} + +interface SequentialAccessibleRowProps { + row: AccessibleRow; + label: string; + selected: boolean; + toggleLabel?: string; + onSelectNode: (nodeId: string) => void; + onToggleCollapse: (nodeId: string, collapsed: boolean) => void; +} + +const SequentialAccessibleRow = memo(function SequentialAccessibleRow({ + row, + label, + selected, + toggleLabel, + onSelectNode, + onToggleCollapse, +}: SequentialAccessibleRowProps) { + return ( +
  • + + {row.collapsible && ( + + )} +
  • + ); +}); + +/** + * Screen-reader fallback used when the visual canvas must virtualize a very + * large graph. The visual ReactFlow subtree is aria-hidden in that mode, while + * this focusable, DOM-ordered list keeps every step reachable and operable. + */ +export function SequentialAccessibleList({ + rows, + nodes, + selectedNodeId, + onSelectNode, + onToggleCollapse, +}: SequentialAccessibleListProps) { + const { _ } = useSafeLingui(); + const labelsById = useMemo( + () => new Map(nodes.map((node) => [node.id, node.ariaLabel ?? node.id])), + [nodes] + ); + const callbacksRef = useRef({ onSelectNode, onToggleCollapse }); + callbacksRef.current = { onSelectNode, onToggleCollapse }; + const selectNode = useCallback((nodeId: string) => callbacksRef.current.onSelectNode(nodeId), []); + const toggleCollapse = useCallback( + (nodeId: string, collapsed: boolean) => + callbacksRef.current.onToggleCollapse(nodeId, collapsed), + [] + ); + + return ( +
      + {rows.map((row) => { + const label = labelsById.get(row.nodeId) ?? row.nodeId; + const toggleLabel = !row.collapsible + ? undefined + : row.collapsed + ? _({ + id: 'sequential-canvas.gutter.expand-step', + message: 'Expand step {stepNumber}', + values: { stepNumber: row.stepNumber }, + }) + : _({ + id: 'sequential-canvas.gutter.collapse-step', + message: 'Collapse step {stepNumber}', + values: { stepNumber: row.stepNumber }, + }); + return ( + + ); + })} +
    + ); +} diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCanvas.roundtrip.test.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCanvas.roundtrip.test.ts new file mode 100644 index 000000000..b3f9e23ad --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCanvas.roundtrip.test.ts @@ -0,0 +1,129 @@ +import type { Edge, Node } from '@uipath/apollo-react/canvas/xyflow/react'; +import { describe, expect, it } from 'vitest'; +import { sequenceFingerprint } from '../../utils/sequential/fingerprint'; +import { makeWireframeFixture, WIREFRAME_NODE_IDS } from '../../utils/sequential/fixtures'; +import { insertAtSlot } from '../../utils/sequential/mutations'; +import { projectSequence } from '../../utils/sequential/projectSequence'; +import { SEQ_INSERTED_FLAG } from './edges/sequentialInsert'; +import { synthesizePositionsForFlow } from './synthesizePositionsForFlow'; +import { deriveSequentialGraph } from './useSequentialGraph'; + +/** + * Acceptance test: insert a node while in the sequential view + * (simulated via the pure insertAtSlot op, exactly what the change-forwarding + * produces from the live pipeline), place it on toggle-to-flow with + * synthesizePositionsForFlow, and assert: + * 1. no inserted node overlaps another node in its frame; + * 2. NO pre-existing node moved (returned by identity, positions intact); + * 3. toggling back yields an identical projection (fingerprint + structure). + */ + +function applyChangeset( + nodes: Node[], + edges: Edge[], + changeset: ReturnType +): { nodes: Node[]; edges: Edge[] } { + const removeNodes = new Set(changeset.removeNodeIds); + const removeEdges = new Set(changeset.removeEdgeIds); + return { + nodes: [...nodes.filter((node) => !removeNodes.has(node.id)), ...changeset.addNodes], + edges: [...edges.filter((edge) => !removeEdges.has(edge.id)), ...changeset.addEdges], + }; +} + +function boxesOverlap(a: Node, b: Node): boolean { + const aw = a.width ?? 96; + const ah = a.height ?? 96; + const bw = b.width ?? 96; + const bh = b.height ?? 96; + return ( + a.position.x < b.position.x + bw && + a.position.x + aw > b.position.x && + a.position.y < b.position.y + bh && + a.position.y + ah > b.position.y + ); +} + +function assertNoOverlapWithinFrames(nodes: Node[]): void { + const byFrame = new Map(); + for (const node of nodes) { + const frame = node.parentId ?? '__root__'; + (byFrame.get(frame) ?? byFrame.set(frame, []).get(frame)!).push(node); + } + for (const frameNodes of byFrame.values()) { + for (let i = 0; i < frameNodes.length; i++) { + for (let j = i + 1; j < frameNodes.length; j++) { + expect(boxesOverlap(frameNodes[i]!, frameNodes[j]!)).toBe(false); + } + } + } +} + +function projectionSignature(nodes: Node[], edges: Edge[]) { + const projection = projectSequence(nodes, edges); + return { + rows: projection.rows.map((row) => ({ + nodeId: row.nodeId, + depth: row.depth, + stepNumber: row.stepNumber, + })), + connectors: projection.connectors.map((connector) => ({ + kind: connector.kind, + source: connector.sourceRowId, + target: connector.targetRowId, + })), + }; +} + +describe('sequential insert round-trip (D4)', () => { + it('inserts, places on toggle-to-flow without moving anything, and re-projects identically', () => { + const { nodes, edges } = makeWireframeFixture(); + + // 1. Derive the sequential view and grab the step slot between HTTP and JS. + const initial = deriveSequentialGraph({ nodes, edges, view: 'sequential' }); + const slot = initial.projection!.slots.find( + (candidate) => candidate.graphEdgeId === 'e-http-js' + ); + expect(slot).toBeDefined(); + + // 2. Simulate the pipeline insert: a new seqInserted node spliced into the slot. + const newNode: Node = { + id: 'inserted-1', + type: 'uipath.slack', + position: { x: 0, y: 0 }, + data: { display: { label: 'Send Slack' }, [SEQ_INSERTED_FLAG]: true }, + }; + const changeset = insertAtSlot(initial.projection!, slot!, newNode); + const afterInsert = applyChangeset(nodes, edges, changeset); + + // The insert is inline (HTTP -> inserted -> Javascript). + const insertedProjection = projectSequence(afterInsert.nodes, afterInsert.edges); + const insertedRow = insertedProjection.rows.find((row) => row.nodeId === 'inserted-1'); + expect(insertedRow?.depth).toBe(0); + + const fingerprintBefore = sequenceFingerprint(afterInsert.nodes, afterInsert.edges, new Set()); + const signatureBefore = projectionSignature(afterInsert.nodes, afterInsert.edges); + + // 3. Toggle to flow: synthesize a real position for the inserted node. + const flowNodes = synthesizePositionsForFlow(afterInsert.nodes, afterInsert.edges); + + // No overlaps anywhere. + assertNoOverlapWithinFrames(flowNodes); + + // No pre-existing node moved: each original node comes back by identity. + for (const id of Object.values(WIREFRAME_NODE_IDS)) { + const original = nodes.find((node) => node.id === id)!; + expect(flowNodes.find((node) => node.id === id)).toBe(original); + } + + // The inserted node now has a real position and no sequential marker. + const placed = flowNodes.find((node) => node.id === 'inserted-1')!; + expect((placed.data as Record)[SEQ_INSERTED_FLAG]).toBeUndefined(); + + // 4. Toggle back to sequential: identical projection (positions + the cleared + // flag are both ignored by the projection and the fingerprint). + const fingerprintAfter = sequenceFingerprint(flowNodes, afterInsert.edges, new Set()); + expect(fingerprintAfter).toBe(fingerprintBefore); + expect(projectionSignature(flowNodes, afterInsert.edges)).toEqual(signatureBefore); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCanvas.stories.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCanvas.stories.tsx new file mode 100644 index 000000000..97041a5e9 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCanvas.stories.tsx @@ -0,0 +1,515 @@ +/** + * Sequential Canvas stories. + * + * These are the flagship stories for the feature: the wireframe reproduction, a + * generated performance graph, and execution status flowing into bars. + * SequentialCanvas is intentionally NOT exported from components/index.ts until + * GA (D13), so every import below is a direct source path. + */ +import type { Meta, StoryObj } from '@storybook/react'; +import type { Edge, Node } from '@uipath/apollo-react/canvas/xyflow/react'; +import { applyEdgeChanges, applyNodeChanges } from '@uipath/apollo-react/canvas/xyflow/react'; +import { Switch, ToggleGroup, ToggleGroupItem } from '@uipath/apollo-wind'; +import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { SEQ_INDENT_PX } from '../../constants'; +import { StoryInfoPanel, withCanvasProviders } from '../../storybook-utils'; +import { + SequentialCanvasStoryHarness, + sequentialWireframeManifests, +} from '../../storybook-utils/sequential'; +import { makeWireframeFixture } from '../../utils/sequential/fixtures'; +import { SequentialCanvas } from './SequentialCanvas'; + +const meta: Meta = { + title: 'Components/Canvas/SequentialCanvas', + parameters: { layout: 'fullscreen' }, + decorators: [withCanvasProviders()], +}; + +export default meta; +type Story = StoryObj; + +const SEQUENCE_DENSITY_PRESETS = [ + { + id: 'compact', + label: 'Compact', + nodeWidth: 512, + nodeHeight: 40, + indentMultiplier: 1, + rowGap: 24, + }, + { + id: 'balanced', + label: 'Balanced', + nodeWidth: 512, + nodeHeight: 48, + indentMultiplier: 1.25, + rowGap: 48, + }, + { + id: 'spacious', + label: 'Spacious', + nodeWidth: 800, + nodeHeight: 64, + indentMultiplier: 2, + rowGap: 64, + }, +] as const; +const BALANCED_DENSITY_PRESET = SEQUENCE_DENSITY_PRESETS[1]; + +// --------------------------------------------------------------------------- +// (a) Interactive Sequence: configurable sequential workflow example. +// --------------------------------------------------------------------------- + +function InteractiveSequenceStory() { + const fixture = useMemo(() => makeWireframeFixture(), []); + const [isReadonly, setIsReadonly] = useState(false); + const [nodeWidth, setNodeWidth] = useState(BALANCED_DENSITY_PRESET.nodeWidth); + const [nodeHeight, setNodeHeight] = useState(BALANCED_DENSITY_PRESET.nodeHeight); + const [indentMultiplier, setIndentMultiplier] = useState( + BALANCED_DENSITY_PRESET.indentMultiplier + ); + const [rowGap, setRowGap] = useState(BALANCED_DENSITY_PRESET.rowGap); + const sequenceLayoutOptions = useMemo( + () => ({ + barWidth: nodeWidth, + barHeight: nodeHeight, + indent: Math.round((SEQ_INDENT_PX * indentMultiplier) / 16) * 16, + rowGap, + }), + [nodeWidth, nodeHeight, indentMultiplier, rowGap] + ); + const activeDensityPreset = + SEQUENCE_DENSITY_PRESETS.find( + (preset) => + preset.nodeWidth === nodeWidth && + preset.nodeHeight === nodeHeight && + preset.indentMultiplier === indentMultiplier && + preset.rowGap === rowGap + )?.id ?? ''; + + const applyDensityPreset = (presetId: string) => { + const preset = SEQUENCE_DENSITY_PRESETS.find(({ id }) => id === presetId); + if (!preset) return; + + setNodeWidth(preset.nodeWidth); + setNodeHeight(preset.nodeHeight); + setIndentMultiplier(preset.indentMultiplier); + setRowGap(preset.rowGap); + }; + + return ( +
    + console.log('Add trigger clicked')} + /> + +
    +
    + Readonly canvas + +
    +
    + Density preset + + {SEQUENCE_DENSITY_PRESETS.map((preset) => ( + + {preset.label} + + ))} + +
    + + + + +
    +
    +
    + ); +} + +export const InteractiveSequence: Story = { + name: 'Interactive Sequence', + parameters: { + docs: { + description: { + story: + 'Reproduces the design concept: a Workflow start bar, HTTP Request, Javascript, a For Each container whose body is an If branching into Then: Javascript 1 and Else: HTTP Request 1, Send Message to User, and the terminal add-step control. Built from utils/sequential/fixtures.ts makeWireframeFixture, the same fixture the projection engine unit tests assert exact step numbers and connectors against.', + }, + }, + }, + render: () => , +}; + +// --------------------------------------------------------------------------- +// (b) Performance: a generated ~150-node graph mixing branches and containers. +// --------------------------------------------------------------------------- + +const PERF_TYPES = { + step: 'uipath.data.transform', + decision: 'uipath.control-flow.decision', + foreach: 'uipath.control-flow.foreach', +} as const; + +const DEFAULT_PERF_UNIT_COUNT = 30; +const MIN_PERF_UNIT_COUNT = 1; +const MAX_PERF_UNIT_COUNT = 60; +const PERF_GRAPH_REBUILD_DEBOUNCE_MS = 150; + +function perfNode(id: string, type: string, label: string, parentId?: string): Node { + return { + id, + type, + position: { x: 0, y: 0 }, + ...(parentId ? { parentId } : {}), + data: { display: { label } }, + }; +} + +function perfEdge( + id: string, + source: string, + sourceHandle: string, + target: string, + targetHandle: string, + label?: string +): Edge { + return { + id, + source, + target, + sourceHandle, + targetHandle, + ...(label ? { data: { label } } : {}), + }; +} + +/** + * Generates a mixed branch/container graph of roughly `unitCount * 5 + 1` + * nodes: a spine of Decision -> (Then/Else) -> For Each[one child], repeated + * `unitCount` times, with each container child looping back into the + * container's `continue` handle. Used to stress-test projection, layout, and + * render cost at scale; D12's structural fingerprint memo is what keeps a + * data-only change (e.g. an inline rename) from re-running either. + */ +function createPerformanceFixture(unitCount: number): { nodes: Node[]; edges: Edge[] } { + const nodes: Node[] = [perfNode('perf-root', PERF_TYPES.step, 'Load batch')]; + const edges: Edge[] = []; + let previousId = 'perf-root'; + + for (let i = 0; i < unitCount; i++) { + const decisionId = `perf-decision-${i}`; + const thenId = `perf-then-${i}`; + const elseId = `perf-else-${i}`; + const foreachId = `perf-foreach-${i}`; + const childId = `perf-child-${i}`; + + nodes.push( + perfNode(decisionId, PERF_TYPES.decision, `Check batch ${i + 1}`), + perfNode(thenId, PERF_TYPES.step, `Transform ${i + 1}`), + perfNode(elseId, PERF_TYPES.step, `Fallback ${i + 1}`), + perfNode(foreachId, PERF_TYPES.foreach, `For each item ${i + 1}`), + perfNode(childId, PERF_TYPES.step, `Process item ${i + 1}`, foreachId) + ); + + edges.push( + perfEdge(`perf-e-${previousId}-${decisionId}`, previousId, 'output', decisionId, 'input'), + perfEdge(`perf-e-${decisionId}-${thenId}`, decisionId, 'true', thenId, 'input', 'Then'), + perfEdge(`perf-e-${decisionId}-${elseId}`, decisionId, 'false', elseId, 'input', 'Else'), + perfEdge(`perf-e-${thenId}-${foreachId}`, thenId, 'output', foreachId, 'input'), + perfEdge(`perf-e-${elseId}-${foreachId}`, elseId, 'output', foreachId, 'input'), + perfEdge(`perf-e-${foreachId}-${childId}`, foreachId, 'start', childId, 'input'), + perfEdge(`perf-e-${childId}-${foreachId}`, childId, 'output', foreachId, 'continue') + ); + + previousId = foreachId; + } + + return { nodes, edges }; +} + +function preservePerformanceFixtureReferences( + current: { nodes: Node[]; edges: Edge[] }, + next: { nodes: Node[]; edges: Edge[] } +): { nodes: Node[]; edges: Edge[] } { + const currentNodesById = new Map(current.nodes.map((node) => [node.id, node])); + const currentEdgesById = new Map(current.edges.map((edge) => [edge.id, edge])); + return { + nodes: next.nodes.map((node) => currentNodesById.get(node.id) ?? node), + edges: next.edges.map((edge) => currentEdgesById.get(edge.id) ?? edge), + }; +} + +interface PerformanceControlsProps { + showDesignAffordances: boolean; + onShowDesignAffordancesChange: (checked: boolean) => void; + onUnitCountCommit: (unitCount: number) => void; +} + +/** + * Keeps high-frequency slider state below SequentialCanvas. If this state lived + * in PerformanceStory, every pointer tick would reconcile the full xyflow tree + * even though graph rebuilding itself was debounced. + */ +function PerformanceControls({ + showDesignAffordances, + onShowDesignAffordancesChange, + onUnitCountCommit, +}: PerformanceControlsProps) { + const [unitCount, setUnitCount] = useState(DEFAULT_PERF_UNIT_COUNT); + const rebuildTimerRef = useRef | null>(null); + const targetNodeCount = unitCount * 5 + 1; + + useEffect( + () => () => { + if (rebuildTimerRef.current) clearTimeout(rebuildTimerRef.current); + }, + [] + ); + + const handleUnitCountChange = useCallback( + (event: React.ChangeEvent) => { + const nextCount = Number.parseInt(event.target.value, 10); + setUnitCount(nextCount); + + if (rebuildTimerRef.current) clearTimeout(rebuildTimerRef.current); + rebuildTimerRef.current = setTimeout(() => { + onUnitCountCommit(nextCount); + rebuildTimerRef.current = null; + }, PERF_GRAPH_REBUILD_DEBOUNCE_MS); + }, + [onUnitCountCommit] + ); + + return ( + +
    + Design affordances + +
    +
    + Nodes + {targetNodeCount} +
    + +
    + ); +} + +function PerformanceStory() { + const [showDesignAffordances, setShowDesignAffordances] = useState(false); + const [collapsedStepIds, setCollapsedStepIds] = useState([]); + const [graph, setGraph] = useState(() => createPerformanceFixture(DEFAULT_PERF_UNIT_COUNT)); + + const handleUnitCountCommit = useCallback((nextCount: number) => { + const nextGraph = createPerformanceFixture(nextCount); + const nextNodeIds = new Set(nextGraph.nodes.map((node) => node.id)); + startTransition(() => { + setGraph((current) => preservePerformanceFixtureReferences(current, nextGraph)); + setCollapsedStepIds((current) => current.filter((id) => nextNodeIds.has(id))); + }); + }, []); + + const onNodesChange = useCallback( + (changes: Parameters>[0]) => + setGraph((current) => ({ + ...current, + nodes: applyNodeChanges(changes, current.nodes), + })), + [] + ); + const onEdgesChange = useCallback( + (changes: Parameters>[0]) => + setGraph((current) => ({ + ...current, + edges: applyEdgeChanges(changes, current.edges), + })), + [] + ); + + return ( + + + + ); +} + +export const Performance: Story = { + name: 'Performance', + parameters: { + docs: { + description: { + story: + 'A generated graph mixing branches and containers, defaulting to about 150 nodes. Use this to profile pan and zoom under load, and to confirm projectSequence reuses its cached layout on data-only changes (D12).', + }, + }, + }, + render: () => , +}; + +// --------------------------------------------------------------------------- +// (c) ExecutionStatus: bars pulling live execution state, one per status. +// --------------------------------------------------------------------------- + +// The Storybook execution-state decorator (storybook-utils/decorators.tsx) +// derives status from the id's second dash-separated segment, so each id here +// is `exec-`. +const EXECUTION_STATUS_STEPS: Array<{ id: string; type: string; label: string }> = [ + { id: 'exec-Completed', type: 'uipath.script', label: 'Validate input' }, + { id: 'exec-InProgress', type: 'uipath.data.transform', label: 'Transform payload' }, + { id: 'exec-ActionNeeded', type: 'uipath.human-task.approval', label: 'Manager approval' }, + { id: 'exec-Failed', type: 'uipath.workflow.call', label: 'Sync to ERP' }, + { id: 'exec-NotExecuted', type: 'uipath.control-flow.decision', label: 'Check retry' }, +]; + +function createExecutionStatusFixture(): { nodes: Node[]; edges: Edge[] } { + const nodes: Node[] = EXECUTION_STATUS_STEPS.map(({ id, type, label }) => ({ + id, + type, + position: { x: 0, y: 0 }, + data: { display: { label } }, + })); + const edges: Edge[] = EXECUTION_STATUS_STEPS.slice(1).map((step, index) => ({ + id: `e-${EXECUTION_STATUS_STEPS[index]!.id}-${step.id}`, + source: EXECUTION_STATUS_STEPS[index]!.id, + target: step.id, + sourceHandle: 'output', + targetHandle: 'input', + })); + return { nodes, edges }; +} + +function ExecutionStatusStory() { + const fixture = useMemo(() => createExecutionStatusFixture(), []); + return ( + + + + ); +} + +export const ExecutionStatus: Story = { + name: 'Execution Status', + parameters: { + docs: { + description: { + story: + 'Five bars, one per execution status: Completed, InProgress, ActionNeeded, Failed, and NotExecuted. Rendered in view mode to demonstrate the sequential view working outside design mode (open product question Q5).', + }, + }, + }, + render: () => , +}; diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCanvas.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCanvas.tsx new file mode 100644 index 000000000..f99739a7c --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCanvas.tsx @@ -0,0 +1,813 @@ +import type { + Edge, + EdgeChange, + EdgeTypes, + Node, + NodeChange, + NodeTypes, + XYPosition, +} from '@uipath/apollo-react/canvas/xyflow/react'; +import { + applyEdgeChanges, + applyNodeChanges, + Position, + ReactFlowProvider, + useReactFlow, +} from '@uipath/apollo-react/canvas/xyflow/react'; +import type { Dispatch, SetStateAction } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + DEFAULT_SOURCE_HANDLE_ID, + DEFAULT_TRIGGER_NODE_TYPE, + PREVIEW_NODE_ID, +} from '../../constants'; +import { useOptionalNodeTypeRegistry } from '../../core'; +import { usePreviewNode } from '../../hooks/usePreviewNode'; +import { isContainerNodeManifest } from '../../utils'; +import { isPreviewEdge } from '../../utils/createPreviewNode'; +import { resolveHandles } from '../../utils/manifest-resolver'; +import { SEQ_LANE_PLACEHOLDER_PREFIX } from '../../utils/sequential/graph-helpers'; +import { removeStep } from '../../utils/sequential/mutations'; +import type { InsertionSlot } from '../../utils/sequential/sequential.types'; +import { AddNodeManager } from '../AddNodePanel/AddNodeManager'; +import { BaseCanvas } from '../BaseCanvas/BaseCanvas'; +import { BaseNode } from '../BaseNode/BaseNode'; +import { SequentialConnectorEdge } from './edges/SequentialConnectorEdge'; +import { SEQUENTIAL_IGNORED_NODE_TYPES } from './edges/sequentialInsert'; +import { useSequentialInsert } from './edges/useSequentialInsert'; +import { + SEQUENTIAL_SYNTHETIC_NODE_TYPES, + SequentialInsertPreviewNode, + SequentialStepNode, +} from './nodes'; +import { resolveFlowEdgeTypes } from './resolveFlowEdgeTypes'; +import { resolveFlowNodeComponent } from './resolveFlowNodeComponent'; +import { SequentialAccessibleList } from './SequentialAccessibleList'; +import type { SequentialCanvasProps } from './SequentialCanvas.types'; +import { SequentialCollapsedRowsProvider } from './SequentialCollapsedRowsContext'; +import { SequentialGutter } from './SequentialGutter'; +import { SequentialInsertGapProvider } from './SequentialInsertGapContext'; +import { SequentialInsertStateProvider } from './SequentialInsertStateContext'; +import { SequentialMoveActionsProvider } from './SequentialMoveActionsContext'; +import { useOptionalSequentialView } from './SequentialViewContext'; +import { + forwardSequentialEdgeChanges, + forwardSequentialNodeChanges, + graphChangeSetToEdgeChanges, + graphChangeSetToNodeChanges, +} from './sequentialChangeFilters'; +import { + SEQ_CONNECTOR_EDGE_TYPE, + SEQ_FULL_RENDER_MAX_NODES, + SEQ_PLACEHOLDER_ROW_ID, +} from './sequentialGraph.constants'; +import { + getSequentialMoveSlot, + resolveTailInsertionSlot, + type SequentialMoveDirection, +} from './sequentialMoveActions'; +import { useCanvasViewViewport } from './useCanvasViewViewport'; +import { SEQ_SYNTHETIC_ROW_IDS, useSequentialGraph } from './useSequentialGraph'; +import { toggleCollapsedStepIds, useSequentialKeyboard } from './useSequentialKeyboard'; +import { useSequentialMoveActionsValue } from './useSequentialMoveActionsValue'; + +const EDGE_TYPES: EdgeTypes = { + [SEQ_CONNECTOR_EDGE_TYPE]: SequentialConnectorEdge as EdgeTypes[string], + // The Add Node preview pipeline stamps its transient edges `type: 'default'`. + // Render those through the same connector so the preview traces the real + // orthogonal rounded path (with an arrowhead) instead of xyflow's built-in + // bezier fallback, which is the only 'default' edge that occurs in this view. + default: SequentialConnectorEdge as EdgeTypes[string], +}; + +const EMPTY_POSITIONS: ReadonlyMap = new Map(); + +/** + * The Sequential Canvas view: an n8n/Zapier-style vertical projection of the + * same flow graph, rendered through the existing BaseCanvas (D1-D14). It renders + * either the canonical flow graph or its sequential projection on the same + * mounted BaseCanvas, selected by the orthogonal `view` prop (see ToggleHarness + * / SequentialViewProvider). Mutations flow out through the standard + * onNodesChange / onEdgesChange callbacks, with synthetic rows and view-only + * geometry filtered out (seam 2); canonical positions are never written back + * (D4). + * + * It supplies its own ReactFlowProvider (BaseCanvas requires one above it, + * BaseCanvas.tsx:116-122) so it is a self-contained drop-in, matching the + * MiniCanvasNavigator idiom rather than requiring the host to wrap it. + */ +export function SequentialCanvas( + props: SequentialCanvasProps +) { + return ( + + + + + + ); +} + +/** + * Owns insertion-gap state above every insertion hook in the canvas. Keeping + * the provider here is essential: terminal/lane/leaf handlers are created by + * SequentialCanvasInner itself, while connector handlers are rendered below + * BaseCanvas. Both must publish into the same state sink before the preview is + * opened. + */ +function SequentialCanvasInsertController( + props: SequentialCanvasProps +) { + const [activeInsertSlot, setActiveInsertSlot] = useState(undefined); + const { previewNode } = usePreviewNode(); + const wasPreviewOpenRef = useRef(false); + + useEffect(() => { + if (previewNode) { + wasPreviewOpenRef.current = true; + } else if (wasPreviewOpenRef.current) { + wasPreviewOpenRef.current = false; + setActiveInsertSlot(undefined); + } + }, [previewNode]); + + return ( + + + + ); +} + +function SequentialCanvasInner({ + nodes, + edges, + view = 'sequential', + sequenceLayoutOptions, + isSequenceNode, + flowNodeTypes, + flowEdgeTypes, + onNodesChange, + onEdgesChange, + collapsedStepIds, + onCollapsedStepIdsChange, + onPrimaryAction, + onAddTrigger, + addNodeManagerProps, + canvasRef, + mode = 'view', + isDarkMode, + locale, + fitViewOptions, + onToolbarAction, + breakpoints, + children, + activeInsertSlot, + setActiveInsertSlot, +}: SequentialCanvasProps & { + activeInsertSlot?: InsertionSlot; + setActiveInsertSlot: Dispatch>; +}) { + const reactFlow = useReactFlow(); + const { startInsert, onBeforeNodeAdded } = useSequentialInsert(); + const seqView = useOptionalSequentialView(); + const registry = useOptionalNodeTypeRegistry(); + const isDesignMode = mode === 'design'; + // Canonical id->node map, reused everywhere a node lookup is needed (handle + // resolution, change forwarding, move commits). `childParentIds` is the set of + // ids that are some node's `parentId`, i.e. structural containers, so + // `isContainerNode` is an O(1) lookup instead of an O(n) scan per call. + const canonicalById = useMemo(() => new Map(nodes.map((node) => [node.id, node])), [nodes]); + const childParentIds = useMemo(() => { + const ids = new Set(); + for (const node of nodes) { + if (node.parentId) ids.add(node.parentId); + } + return ids; + }, [nodes]); + + const resolvedHandlesByNodeId = useMemo(() => { + const result = new Map>(); + for (const node of nodes) { + const manifest = node.type ? registry?.getManifest(node.type) : undefined; + if (!manifest) continue; + result.set( + node.id, + resolveHandles(manifest.handleConfiguration, { + ...(node.data as Record), + nodeId: node.id, + }) + ); + } + return result; + }, [nodes, registry]); + + const findResolvedHandle = useCallback( + (nodeId: string, handleId: string | null | undefined, type: 'source' | 'target') => { + const nodeType = canonicalById.get(nodeId)?.type; + const effectiveHandleId = + handleId ?? (nodeType ? registry?.getDefaultHandle(nodeType, type)?.id : undefined); + const groups = resolvedHandlesByNodeId.get(nodeId); + if (!groups) return undefined; + for (const group of groups) { + for (const handle of group.handles) { + if (handle.type === type && handle.id === effectiveHandleId) return handle; + } + } + return undefined; + }, + [resolvedHandlesByNodeId, canonicalById, registry] + ); + + const isSequenceEdge = useCallback( + (edge: E) => + findResolvedHandle(edge.source, edge.sourceHandle, 'source')?.handleType !== 'artifact' && + findResolvedHandle(edge.target, edge.targetHandle, 'target')?.handleType !== 'artifact', + [findResolvedHandle] + ); + + const isStartNode = useCallback( + (node: N) => + node.type === DEFAULT_TRIGGER_NODE_TYPE || + (node.type ? registry?.getManifest(node.type)?.category === 'trigger' : false), + [registry] + ); + + const isProjectedContainerNode = useCallback( + (node: N) => isContainerNodeManifest(node.type ? registry?.getManifest(node.type) : undefined), + [registry] + ); + + const isProjectedSequenceNode = useCallback( + (node: N) => (isSequenceNode ? isSequenceNode(node) : node.type !== 'stickyNote'), + [isSequenceNode] + ); + + const resolveBranchLabel = useCallback( + (nodeId: string, handleId: string) => + findResolvedHandle(nodeId, handleId, 'source')?.label ?? handleId, + [findResolvedHandle] + ); + + // A parent node's resolved branch-lane handles (If → true/false, while → body, + // try/catch → try/catch/finally), so the projection can render every lane — + // empty ones as "+ Add step" placeholders — before any child edge exists. The + // continuation output is excluded when it uses a well-known `output` or + // `success` handle, or when it is the node's only source. `isDefaultForType` + // is intentionally not used as a branch discriminator: it describes default + // connection selection, not control-flow semantics. + const getBranchHandles = useCallback( + (node: N) => { + const candidates = (resolvedHandlesByNodeId.get(node.id) ?? []) + .flatMap((group) => group.handles) + .filter( + (handle) => handle.type === 'source' && handle.handleType !== 'artifact' && handle.visible + ); + const continuationId = + candidates.find((handle) => handle.id === 'output' || handle.id === 'success')?.id ?? + (candidates.length === 1 ? candidates[0]?.id : undefined); + return candidates + .filter((handle) => handle.id !== continuationId) + .map((handle) => ({ id: handle.id, label: resolveBranchLabel(node.id, handle.id) })); + }, + [resolvedHandlesByNodeId, resolveBranchLabel] + ); + + const collapsedSet = useMemo(() => new Set(collapsedStepIds ?? []), [collapsedStepIds]); + + // The slot whose Add Node panel is currently open, if any. Written by every + // useSequentialInsert() call site via SequentialInsertGapProvider, and cleared + // the moment the preview node closes -- covering BOTH cancel (the gap just + // closes) and commit (a real structural change relayouts right behind it; a + // same-frame flicker is acceptable). While set, useSequentialGraph re-runs ONLY + // its cheap layout pass with a preview row spliced in at the slot (the + // projection memo stays fingerprint-keyed, D12), so the gap opens exactly one + // row, the preview bar seats in its slot, and every connector re-routes from + // the shifted geometry. + // Terminal "Add step" placeholder: append after the last top-level row. Read + // from a ref so the callback identity stays stable (it feeds the synthetic + // placeholder's data, which must not churn every render). + const tailSlotRef = useRef(undefined); + const onPlaceholderAdd = useCallback(() => { + if (!isDesignMode || view !== 'sequential') return; + const slot = tailSlotRef.current; + if (!slot?.source) return; + const placeholder = reactFlow.getNode(SEQ_PLACEHOLDER_ROW_ID); + startInsert({ + slot, + source: slot.source.nodeId, + sourceHandleId: slot.source.handleId ?? DEFAULT_SOURCE_HANDLE_ID, + target: '', + targetHandleId: undefined, + sourcePosition: Position.Bottom, + position: placeholder?.position ?? { x: 0, y: 0 }, + }); + }, [isDesignMode, view, reactFlow, startInsert]); + + // An empty branch lane's "+ Add step" placeholder: append the first node into + // that lane via the carried slot (source = parent, sourceHandle = the branch + // handle, containerId set so it parents correctly). The preview then re-seats + // in the lane via the layout swap (projectionWithPreviewRow). + const onLaneAdd = useCallback( + (slot: InsertionSlot) => { + if (!isDesignMode || view !== 'sequential' || !slot.source) return; + const sourceNode = canonicalById.get(slot.source.nodeId); + const resolvedSource = { + ...slot.source, + handleId: + slot.source.handleId ?? + (sourceNode?.type + ? registry?.getDefaultHandle(sourceNode.type, 'source')?.id + : undefined) ?? + DEFAULT_SOURCE_HANDLE_ID, + }; + const resolvedSlot: InsertionSlot = { + ...slot, + source: resolvedSource, + }; + const placeholder = reactFlow + .getNodes() + .find( + (node) => + (node.data as { insertionSlotId?: string } | undefined)?.insertionSlotId === slot.id + ); + startInsert({ + slot: resolvedSlot, + source: resolvedSource.nodeId, + sourceHandleId: undefined, + target: '', + targetHandleId: undefined, + sourcePosition: Position.Bottom, + position: placeholder?.position ?? { x: 0, y: 0 }, + }); + }, + [isDesignMode, view, canonicalById, registry, reactFlow, startInsert] + ); + + const { + nodes: seqNodesRaw, + edges: seqEdges, + projection, + layout, + } = useSequentialGraph({ + nodes, + edges, + view, + collapsedStepIds: collapsedSet, + onAddTrigger: isDesignMode && view === 'sequential' ? onAddTrigger : undefined, + onPlaceholderAdd: isDesignMode && view === 'sequential' ? onPlaceholderAdd : undefined, + isSequenceEdge, + isSequenceNode: isProjectedSequenceNode, + isStartNode, + isContainerNode: isProjectedContainerNode, + resolveBranchLabel, + getBranchHandles, + onLaneAdd: isDesignMode && view === 'sequential' ? onLaneAdd : undefined, + insertSlot: view === 'sequential' ? activeInsertSlot : undefined, + layoutOptions: sequenceLayoutOptions, + }); + + const tailSlot = useMemo(() => { + return resolveTailInsertionSlot( + projection, + nodes, + (nodeType) => registry?.getDefaultHandle(nodeType, 'source')?.id, + isStartNode + ); + }, [projection, nodes, registry, isStartNode]); + tailSlotRef.current = tailSlot; + + // The Add Node pipeline (showPreviewGraph) adds its `preview` node + preview + // edges via `instance.setNodes` / `instance.setEdges`. This canvas is + // CONTROLLED (nodes/edges come from the derived arrays), and in that mode + // xyflow routes them through onNodesChange / onEdgesChange as `add` changes + // rather than writing the store; the handlers below capture the preview into + // this local state and we merge it into the controlled arrays here (it never + // reaches canonical state -- the change filters drop PREVIEW_NODE_ID / + // isPreviewEdge). The preview node's position is overridden with the slot the + // layout opened (layout.positions[PREVIEW_NODE_ID]), so the ghost bar sits + // centered in the gap instead of at the click-time midpoint. + const [insertPreviewNode, setInsertPreviewNode] = useState(null); + const [insertPreviewEdges, setInsertPreviewEdges] = useState([]); + const seqNodes = useMemo(() => { + // A lane placeholder loses its layout position when its lane is being + // inserted into (projectionWithPreviewRow swaps it for the preview row), so + // drop the now-orphaned placeholder; every other lane placeholder still has a + // position and renders normally. + const base = seqNodesRaw.filter( + (node) => !node.id.startsWith(SEQ_LANE_PLACEHOLDER_PREFIX) || !!layout?.positions.has(node.id) + ); + if (view !== 'sequential' || !insertPreviewNode) return base; + const slotPosition = layout?.positions.get(PREVIEW_NODE_ID); + const positionedPreview = slotPosition + ? { + ...insertPreviewNode, + position: slotPosition, + width: sequenceLayoutOptions?.barWidth ?? insertPreviewNode.width, + height: sequenceLayoutOptions?.barHeight ?? insertPreviewNode.height, + } + : { + ...insertPreviewNode, + width: sequenceLayoutOptions?.barWidth ?? insertPreviewNode.width, + height: sequenceLayoutOptions?.barHeight ?? insertPreviewNode.height, + }; + return [...base, positionedPreview]; + }, [ + view, + seqNodesRaw, + insertPreviewNode, + layout, + sequenceLayoutOptions?.barWidth, + sequenceLayoutOptions?.barHeight, + ]); + const seqEdgesWithPreview = useMemo( + () => + // While a slot is active, useSequentialGraph has already replaced the + // affected connector with routed preview connectors. The raw edges from + // showPreviewGraph remain useful to AddNodeManager's store bookkeeping, + // but rendering them too would duplicate the path and use click-time + // geometry that jumps when the real node is committed. + view === 'sequential' && activeInsertSlot + ? seqEdges + : view === 'sequential' && insertPreviewEdges.length > 0 + ? [...seqEdges, ...insertPreviewEdges] + : seqEdges, + [view, activeInsertSlot, seqEdges, insertPreviewEdges] + ); + useEffect(() => { + if (view !== 'flow') return; + setInsertPreviewNode(null); + setInsertPreviewEdges([]); + setActiveInsertSlot(undefined); + }, [view, setActiveInsertSlot]); + + // Every real manifest type maps to the bar step node; synthetic rows use their + // own components. Memoized on the set of types present so it is reference-stable + // across data/position changes. + const nodeTypeKey = useMemo( + () => [...new Set(nodes.map((node) => node.type ?? 'default'))].sort().join('|'), + [nodes] + ); + const sequentialNodeTypes = useMemo(() => { + const types: NodeTypes = { ...SEQUENTIAL_SYNTHETIC_NODE_TYPES }; + for (const key of nodeTypeKey.split('|')) { + if (key && !types[key]) types[key] = SequentialStepNode; + } + types.default ??= SequentialStepNode; + // The Add Node pipeline (showPreviewGraph) drops a `preview`-typed node into + // the store while the panel is open; without this it cannot render (D2). The + // sequential view uses a bar-shaped ghost (icon left + "New step") instead of + // the flow-view square AddNodePreview, which reads as an empty slab at the + // bar's 16:1 aspect ratio. + types.preview ??= SequentialInsertPreviewNode; + return types; + }, [nodeTypeKey]); + const resolvedFlowNodeTypes = useMemo(() => { + if (flowNodeTypes) return flowNodeTypes; + const types: NodeTypes = {}; + for (const key of nodeTypeKey.split('|')) { + if (key) { + types[key] = resolveFlowNodeComponent(registry?.getManifest(key)); + } + } + types.default ??= BaseNode; + return types; + }, [flowNodeTypes, nodeTypeKey, registry]); + const nodeTypes = view === 'sequential' ? sequentialNodeTypes : resolvedFlowNodeTypes; + const resolvedFlowEdgeTypes = useMemo(() => resolveFlowEdgeTypes(flowEdgeTypes), [flowEdgeTypes]); + const edgeTypes = view === 'sequential' ? EDGE_TYPES : resolvedFlowEdgeTypes; + + // Change forwarding (seam 2): position/dimension changes and synthetic rows are + // dropped; renames merge onto canonical; inserts + their healed edges pass + // through and the split canonical edge is removed. + const canonicalEdgeIds = useMemo(() => new Set(edges.map((edge) => edge.id)), [edges]); + const syntheticNodeIds = useMemo(() => { + const ids = new Set(SEQ_SYNTHETIC_ROW_IDS); + for (const row of projection?.rows ?? []) { + if (row.lanePlaceholder) ids.add(row.nodeId); + } + return ids; + }, [projection]); + + const handleNodesChange = useCallback( + (changes: NodeChange[]) => { + if (view === 'flow') { + onNodesChange?.(changes); + return; + } + // Capture preview-node changes into local state (see the insertPreview + // note above): apply them with xyflow's own reducer so add / remove / + // replace / select / dimensions all behave correctly, then merge the + // result into the controlled array. This runs regardless of whether the + // host wired onNodesChange, since the preview is a view-only affordance. + const previewChanges = changes.filter( + (change) => (change.type === 'add' ? change.item.id : change.id) === PREVIEW_NODE_ID + ); + if (previewChanges.length > 0) { + setInsertPreviewNode((current) => { + const next = applyNodeChanges(previewChanges, current ? [current] : []); + return (next[0] as N | undefined) ?? null; + }); + } + + if (!onNodesChange) return; + const forwarded = forwardSequentialNodeChanges(changes, syntheticNodeIds, canonicalById); + if (forwarded.length > 0) onNodesChange(forwarded); + }, + [view, onNodesChange, syntheticNodeIds, canonicalById] + ); + + const handleEdgesChange = useCallback( + (changes: EdgeChange[]) => { + if (view === 'flow') { + onEdgesChange?.(changes); + return; + } + // Capture preview-edge changes the same way: an `add` is a preview edge + // when isPreviewEdge matches; a remove/select/replace targets a preview + // edge when it references one we are already holding. + setInsertPreviewEdges((current) => { + const relevant = changes.filter((change) => + change.type === 'add' + ? isPreviewEdge(change.item) + : current.some((edge) => edge.id === change.id) + ); + return relevant.length > 0 ? (applyEdgeChanges(relevant, current) as E[]) : current; + }); + + if (!onEdgesChange) return; + const forwarded = forwardSequentialEdgeChanges(changes, canonicalEdgeIds, edges); + if (forwarded.length > 0) onEdgesChange(forwarded); + }, + [view, onEdgesChange, canonicalEdgeIds, edges] + ); + + // Explorer-like tree move operations: kebab items (BaseNode's new `extraMenuItems`, see + // useSequentialMoveMenuItems) and Alt+Arrow keyboard both read/commit + // through this SAME binding, so the two affordances can never disagree. + const isContainerNode = useCallback( + (nodeId: string) => { + if (childParentIds.has(nodeId)) return true; + const node = canonicalById.get(nodeId); + return !!node && isProjectedContainerNode(node); + }, + [canonicalById, childParentIds, isProjectedContainerNode] + ); + + const getDefaultSourceHandleId = useCallback( + (nodeType: string) => registry?.getDefaultHandle(nodeType, 'source')?.id, + [registry] + ); + + const deleteStep = useCallback( + (nodeId: string) => { + if (!isDesignMode || view !== 'sequential' || !projection) return; + const changeSet = removeStep(projection, nodeId, { nodes, edges }); + onNodesChange?.(graphChangeSetToNodeChanges(changeSet)); + onEdgesChange?.(graphChangeSetToEdgeChanges(changeSet)); + }, + [isDesignMode, view, projection, nodes, edges, onNodesChange, onEdgesChange] + ); + + const handleToolbarAction = useCallback< + NonNullable['onToolbarAction']> + >( + (event) => { + if (view === 'sequential' && event.actionId === 'delete') { + deleteStep(event.nodeId); + return; + } + onToolbarAction?.(event); + }, + [view, deleteStep, onToolbarAction] + ); + + // Reference-chip navigation (D9 density fallback) shares this same binding + // (team guidance: thread it "via the same context as the move actions"). + // Mirrors BaseCanvas's own centerNode (BaseCanvas.hooks.ts) directly against + // the raw ReactFlow instance already held above, since that instance -- not + // the imperative BaseCanvasRef, which is only for external consumers -- is + // what this component itself has in scope. + const centerViewportOnNode = useCallback( + (nodeId: string) => { + const node = reactFlow.getInternalNode(nodeId); + if (!node) return; + const width = node.measured?.width ?? node.width; + const height = node.measured?.height ?? node.height; + if (!width || !height) return; + reactFlow.setCenter(node.position.x + width / 2, node.position.y + height / 2, { + zoom: reactFlow.getViewport().zoom, + duration: 300, + }); + }, + [reactFlow] + ); + + // Move-actions binding shared by the kebab items and Alt+Arrow keyboard. + // Rebuilt only on a structural projection change, so its identity is stable + // across a selection or data-only change; every consuming SequentialStepNode + // reads it through context, so a churning value would re-render every bar on + // each click. The commit path reads the latest graph at call time. See + // useSequentialMoveActionsValue. + const moveActionsValue = useSequentialMoveActionsValue({ + projection, + nodes, + edges, + canonicalById, + isContainerNode, + getDefaultSourceHandleId, + onNodesChange, + onEdgesChange, + centerOnNode: centerViewportOnNode, + }); + + // Alt+Arrow keyboard move: same guards as the kebab (design mode + // only) and the same commit path (commitMove), so kebab and keyboard can + // never disagree. + const handleMoveNode = useCallback( + (nodeId: string, direction: SequentialMoveDirection) => { + if (!isDesignMode) return; + const slot = getSequentialMoveSlot(moveActionsValue.getMoveOptions(nodeId), direction); + if (slot) moveActionsValue.commitMove(nodeId, slot); + }, + [isDesignMode, moveActionsValue] + ); + + // Gutter + keyboard (D8): the visible, numbered row order is the single + // source of truth both the gutter's layout and ArrowUp/Down navigation walk, + // so DOM order / gutter order / keyboard order can never disagree. + const visibleNumberedRows = useMemo( + () => projection?.rows.filter((row) => row.visible && row.stepNumber !== undefined) ?? [], + [projection] + ); + + // Sequential bars are not user-connectable, but selection still flows through + // the canonical `nodes` array (the sequential clones pass `selected` through + // by reference, see useSequentialGraph) -- read it back the same way so + // keyboard nav agrees with whatever a mouse click last selected. + const selectedNodeId = useMemo(() => nodes.find((node) => node.selected)?.id, [nodes]); + + const isInteractive = mode !== 'readonly'; + + // Moves single selection to a row's node id through the SAME onNodesChange + // path a mouse click already uses (`select` NodeChanges), so keyboard and + // pointer selection are indistinguishable to the consumer's canonical state. + // Gated on `isInteractive` to mirror BaseCanvas's own selection gating + // (BaseCanvas.tsx onNodeClick/elementsSelectable), so readonly canvases don't + // let the keyboard select what a click cannot. + const handleSelectRow = useCallback( + (nodeId: string) => { + if (!isInteractive) return; + const changes: NodeChange[] = []; + for (const node of nodes) { + if (node.id === nodeId) { + if (!node.selected) changes.push({ id: node.id, type: 'select', selected: true }); + } else if (node.selected) { + changes.push({ id: node.id, type: 'select', selected: false }); + } + } + if (changes.length > 0) handleNodesChange(changes); + }, + [isInteractive, nodes, handleNodesChange] + ); + + // Collapse is view-local UI (D6), not a canonical mutation, so it stays + // available regardless of mode (a readonly/monitoring canvas can still + // fold sections). Shared by both the gutter's chevron click and the + // keyboard's ArrowLeft/Right so the two affordances can never disagree. + const handleToggleCollapse = useCallback( + (nodeId: string, collapsed: boolean) => { + onCollapsedStepIdsChange?.(toggleCollapsedStepIds(collapsedSet, nodeId, collapsed)); + }, + [collapsedSet, onCollapsedStepIdsChange] + ); + + const { onKeyDown } = useSequentialKeyboard({ + rows: visibleNumberedRows, + selectedNodeId, + collapsedStepIds: collapsedSet, + onSelectNode: handleSelectRow, + onCollapsedStepIdsChange, + onMoveNode: handleMoveNode, + onDeleteNode: deleteStep, + onPrimaryAction, + isDesignMode, + }); + + const composedOnBeforeNodeAdded = useCallback( + (newNode: Node, newEdges: Edge[]) => { + const hostResult = addNodeManagerProps?.onBeforeNodeAdded?.(newNode, newEdges) ?? { + newNode, + newEdges, + }; + return onBeforeNodeAdded(hostResult.newNode, hostResult.newEdges); + }, + [addNodeManagerProps?.onBeforeNodeAdded, onBeforeNodeAdded] + ); + const ignoredNodeTypes = useMemo( + () => [ + ...new Set([ + ...SEQUENTIAL_IGNORED_NODE_TYPES, + ...(addNodeManagerProps?.ignoredNodeTypes ?? []), + ]), + ], + [addNodeManagerProps?.ignoredNodeTypes] + ); + const { + onBeforeNodeAdded: _hostOnBeforeNodeAdded, + ignoredNodeTypes: _hostIgnoredNodeTypes, + ...remainingAddNodeManagerProps + } = addNodeManagerProps ?? {}; + + // Per-view viewport save/restore (D11). The hook keeps a local fallback so a + // standalone SequentialCanvas does not re-center on every toggle, and mirrors + // it into SequentialViewProvider when the host opts into persisted state. + const { onMove: handleMove, defaultViewport } = useCanvasViewViewport({ + view, + reactFlow, + externalStore: seqView, + fitViewOptions, + fitOnMount: view === 'sequential', + }); + + // Render every row into the DOM for reading-order a11y (D8), but only up to a + // ceiling: past it, re-enable xyflow's viewport virtualization so a mount + // burst of many hundred fixed bars can't drive xyflow's ResizeObserver / + // updateNodeInternals cycle past React's nested-update limit. See + // SEQ_FULL_RENDER_MAX_NODES for the tradeoff. + const virtualizeForScale = view === 'sequential' && seqNodes.length > SEQ_FULL_RENDER_MAX_NODES; + + return ( + // Keyboard nav (D8) is attached to this wrapper div rather than `document` + // (see useSequentialKeyboard's doc): it only reacts once focus/bubbling + // genuinely lands inside this canvas, so multiple canvases on one page + // never steal each other's key events. + // + // SequentialCollapsedRowsProvider wraps the whole subtree so every + // SequentialStepNode xyflow mounts underneath BaseCanvas can read the + // collapsed row-id set and render the "stacked" treatment without the + // collapse toggle ever touching node.data (D12). + + +
    + + ref={canvasRef} + nodes={seqNodes} + edges={seqEdgesWithPreview} + nodeTypes={nodeTypes} + edgeTypes={edgeTypes} + mode={mode} + isDarkMode={isDarkMode} + locale={locale} + fitViewOptions={fitViewOptions} + onToolbarAction={handleToolbarAction} + breakpoints={breakpoints} + // Accessibility: keep every row in the DOM so reading order == row + // order (D8), except past SEQ_FULL_RENDER_MAX_NODES where + // virtualization is re-enabled for stability at scale. + onlyRenderVisibleElements={virtualizeForScale} + aria-hidden={virtualizeForScale || undefined} + // Sequential bars are not user-connectable in v1. + nodesConnectable={view === 'flow'} + onNodesChange={handleNodesChange} + onEdgesChange={handleEdgesChange} + onMove={handleMove} + defaultViewport={defaultViewport} + > + {children} + {view === 'sequential' && ( + <> + {isDesignMode && ( + + )} + + + )} + + {virtualizeForScale && projection && ( + + )} +
    +
    +
    + ); +} diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCanvas.types.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCanvas.types.ts new file mode 100644 index 000000000..08cdbb69a --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCanvas.types.ts @@ -0,0 +1,70 @@ +import type { + Edge, + EdgeTypes, + Node, + NodeTypes, + OnEdgesChange, + OnNodesChange, +} from '@uipath/apollo-react/canvas/xyflow/react'; +import type { Ref } from 'react'; +import type { CanvasView, LayoutSequenceOptions } from '../../utils/sequential/sequential.types'; +import type { AddNodeManagerProps } from '../AddNodePanel/AddNodeManager'; +import type { BaseCanvasProps, BaseCanvasRef } from '../BaseCanvas/BaseCanvas.types'; + +/** + * Public props for the sequential view. It renders through the existing + * BaseCanvas, so it borrows a curated subset of BaseCanvasProps and never + * exposes the free-form node/edge handlers directly. Mutations still flow out + * through onNodesChange / onEdgesChange (D10); synthetic rows (start bar, + * placeholder) are filtered out before those callbacks fire. + */ +export interface SequentialCanvasProps + extends Pick< + BaseCanvasProps, + | 'mode' + | 'isDarkMode' + | 'locale' + | 'fitViewOptions' + | 'onToolbarAction' + | 'breakpoints' + | 'children' + > { + /** Canonical graph; flow-view positions are untouched (D4). */ + nodes: N[]; + edges: E[]; + /** Render the canonical flow graph or its sequential projection without remounting BaseCanvas. */ + view?: CanvasView; + /** Optional sequential-view geometry overrides. Flow-view geometry is untouched. */ + sequenceLayoutOptions?: LayoutSequenceOptions; + /** + * Controls which canonical nodes participate in the sequential projection. + * Excluded presentation-only nodes remain untouched and reappear in Flow. + * Sticky notes are excluded by default. + */ + isSequenceNode?: (node: N) => boolean; + /** Node registrations used while `view="flow"`. */ + flowNodeTypes?: NodeTypes; + /** Flow edge registrations merged over the standard `SequenceEdge` default. */ + flowEdgeTypes?: EdgeTypes; + /** Synthetic rows are filtered out before forwarding. */ + onNodesChange?: OnNodesChange; + onEdgesChange?: OnEdgesChange; + /** Controlled, view-local collapse state (D6). */ + collapsedStepIds?: string[]; + onCollapsedStepIdsChange?: (ids: string[]) => void; + /** Primary keyboard action invoked by Enter on the selected step. */ + onPrimaryAction?: (nodeId: string) => void; + /** "Add trigger" button on the start bar. */ + onAddTrigger?: () => void; + addNodeManagerProps?: Partial; + canvasRef?: Ref>; +} + +/** + * Segmented flow/sequential control. Controlled: the host owns `value` and + * `onChange`. + */ +export interface ViewSwitcherProps { + value: CanvasView; + onChange: (view: CanvasView) => void; +} diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCollapsedRowsContext.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCollapsedRowsContext.tsx new file mode 100644 index 000000000..f119eab8a --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCollapsedRowsContext.tsx @@ -0,0 +1,43 @@ +import { createContext, type ReactNode, useContext } from 'react'; + +const EMPTY_COLLAPSED_ROWS: ReadonlySet = new Set(); + +const SequentialCollapsedRowsContext = createContext>(EMPTY_COLLAPSED_ROWS); + +export interface SequentialCollapsedRowsProviderProps { + children: ReactNode; + /** The view-local collapsed row-id set (SequentialCanvasInner's `collapsedSet`). */ + collapsedStepIds: ReadonlySet; +} + +/** + * Carries the Sequential Canvas's view-local `collapsedStepIds` set down to + * `SequentialStepNode` without touching node `data`. A + * collapsed collapsible row must render its bar with the same decorative + * "stacked" treatment BaseNode's card uses for drillable/collapsed nodes, but + * the sequential clone's `data` is a by-reference passthrough from the + * canonical node (see `useSequentialGraph.ts`) and must not be mutated (D12); + * mutating it per collapse toggle would also break the clone/canonical + * reference-equality memoization that keeps rename keystrokes cheap. + * + * Kept as a tiny dedicated context (rather than folding into the broader, + * OPTIONAL `SequentialViewContext`) because `collapsedStepIds` is a required, + * always-present concept of `SequentialCanvas` itself, whereas + * `SequentialViewContext` only exists for hosts that compose an explicit + * flow/sequential toggle and may not be mounted at all. + */ +export function SequentialCollapsedRowsProvider({ + children, + collapsedStepIds, +}: SequentialCollapsedRowsProviderProps) { + return ( + + {children} + + ); +} + +/** Returns the current collapsed row-id set; empty outside a provider (e.g. in isolated node tests/stories). */ +export function useSequentialCollapsedRows(): ReadonlySet { + return useContext(SequentialCollapsedRowsContext); +} diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialGutter.test.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialGutter.test.tsx new file mode 100644 index 000000000..b1ffb4f0c --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialGutter.test.tsx @@ -0,0 +1,254 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { SequentialGutterRow } from './SequentialGutter'; +import { SequentialGutter } from './SequentialGutter'; + +function makeRow(overrides: Partial = {}): SequentialGutterRow { + return { + nodeId: 'step-1', + stepNumber: 1, + collapsible: false, + collapsed: false, + ...overrides, + }; +} + +describe('SequentialGutter', () => { + it('renders nothing when there are no rows', () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + }); + + it('renders the step number for every visible numbered row, right-aligned to its bar position', () => { + const rows = [makeRow({ nodeId: 'a', stepNumber: 1 }), makeRow({ nodeId: 'b', stepNumber: 2 })]; + const positions = new Map([ + ['a', { x: 0, y: 0 }], + ['b', { x: 0, y: 120 }], + ]); + render( + + ); + + const numbers = screen.getAllByTestId('sequential-gutter-number'); + expect(numbers.map((el) => el.textContent)).toEqual(['1', '2']); + }); + + it('skips a row with no known position instead of throwing', () => { + const rows = [makeRow({ nodeId: 'a', stepNumber: 1 })]; + render( + + ); + expect(screen.queryByTestId('sequential-gutter-number')).not.toBeInTheDocument(); + }); + + it('renders a chevron only for collapsible rows', () => { + const rows = [ + makeRow({ nodeId: 'a', stepNumber: 1, collapsible: false }), + makeRow({ nodeId: 'b', stepNumber: 2, collapsible: true }), + ]; + const positions = new Map([ + ['a', { x: 0, y: 0 }], + ['b', { x: 0, y: 120 }], + ]); + render( + + ); + + // Only one row is collapsible, so exactly one disclosure button renders. + expect(screen.getAllByRole('button')).toHaveLength(1); + }); + + it('sets aria-expanded=true and an expand-vs-collapse label from collapsed state', () => { + const rows = [makeRow({ nodeId: 'a', stepNumber: 3, collapsible: true, collapsed: false })]; + const positions = new Map([['a', { x: 64, y: 0 }]]); + render( + + ); + + const button = screen.getByRole('button', { name: 'Collapse step 3' }); + expect(button).toHaveAttribute('aria-expanded', 'true'); + }); + + it('reflects a collapsed row as aria-expanded=false with an expand label', () => { + const rows = [makeRow({ nodeId: 'a', stepNumber: 3, collapsible: true, collapsed: true })]; + const positions = new Map([['a', { x: 64, y: 0 }]]); + render( + + ); + + const button = screen.getByRole('button', { name: 'Expand step 3' }); + expect(button).toHaveAttribute('aria-expanded', 'false'); + }); + + it('toggles collapse on click, flipping the current collapsed state', () => { + const onToggleCollapse = vi.fn(); + const rows = [makeRow({ nodeId: 'a', stepNumber: 1, collapsible: true, collapsed: false })]; + const positions = new Map([['a', { x: 64, y: 0 }]]); + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Collapse step 1' })); + expect(onToggleCollapse).toHaveBeenCalledWith('a', true); + }); + + it('marks the number span aria-hidden since the row aria-label lives on the bar itself', () => { + const rows = [makeRow({ nodeId: 'a', stepNumber: 1 })]; + const positions = new Map([['a', { x: 0, y: 0 }]]); + render( + + ); + expect(screen.getByTestId('sequential-gutter-number')).toHaveAttribute('aria-hidden', 'true'); + }); + + // The tree-rail rework. All numbers must sit in ONE fixed + // column regardless of a row's own indent depth, with a dotted leader line + // bridging out to each row's own (depth-varying) bar position. + describe('tree-rail geometry', () => { + it("positions every row column at the SAME fixed x, regardless of the row's own depth", () => { + const rows = [ + makeRow({ nodeId: 'a', stepNumber: 1 }), + makeRow({ nodeId: 'b', stepNumber: 2 }), + ]; + const positions = new Map([ + ['a', { x: 0, y: 0 }], // depth 0 + ['b', { x: 128, y: 120 }], // depth 2 (2 * SEQ_INDENT_PX) + ]); + render( + + ); + + const columns = screen.getAllByTestId('sequential-gutter-row'); + const lefts = columns.map((el) => el.style.left); + expect(lefts[0]).toBe(lefts[1]); + }); + + it('keeps the chevron immediately next to its number inside that same fixed column', () => { + const rows = [makeRow({ nodeId: 'a', stepNumber: 3, collapsible: true })]; + const positions = new Map([['a', { x: 128, y: 0 }]]); + render( + + ); + + const column = screen.getByTestId('sequential-gutter-row'); + expect(column).toContainElement(screen.getByRole('button')); + expect(column).toContainElement(screen.getByTestId('sequential-gutter-number')); + }); + + it("renders one dotted leader line per row, from the fixed column out to that row's own bar position", () => { + const rows = [ + makeRow({ nodeId: 'a', stepNumber: 1 }), + makeRow({ nodeId: 'b', stepNumber: 2 }), + ]; + const positions = new Map([ + ['a', { x: 0, y: 0 }], + ['b', { x: 128, y: 120 }], + ]); + render( + + ); + + const leaders = screen.getAllByTestId('sequential-gutter-leader'); + expect(leaders).toHaveLength(2); + expect(leaders[0].className).toContain('border-dotted'); + expect(leaders[0]).toHaveAttribute('aria-hidden', 'true'); + + // The deeper row's leader must span further right (it bridges a wider gap + // from the shared column to its own more-indented bar). + const widthA = Number.parseFloat(leaders[0].style.width); + const widthB = Number.parseFloat(leaders[1].style.width); + expect(widthB).toBeGreaterThan(widthA); + }); + + it('vertically centers the leader line on its row', () => { + const rows = [makeRow({ nodeId: 'a', stepNumber: 1 })]; + const positions = new Map([['a', { x: 64, y: 100 }]]); + render( + + ); + + const leader = screen.getByTestId('sequential-gutter-leader'); + // top = row.y + SEQ_BAR_HEIGHT / 2 = 100 + 28. + expect(Number.parseFloat(leader.style.top)).toBe(128); + }); + + it('uses a custom bar height for the number row and leader center', () => { + const rows = [makeRow({ nodeId: 'a', stepNumber: 1 })]; + const positions = new Map([['a', { x: 64, y: 100 }]]); + render( + + ); + + expect(screen.getByTestId('sequential-gutter-row').style.height).toBe('48px'); + expect(screen.getByTestId('sequential-gutter-leader').style.top).toBe('124px'); + }); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialGutter.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialGutter.tsx new file mode 100644 index 000000000..16d0fa870 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialGutter.tsx @@ -0,0 +1,165 @@ +import type { XYPosition } from '@uipath/apollo-react/canvas/xyflow/react'; +import { ViewportPortal } from '@uipath/apollo-react/canvas/xyflow/react'; +import { Fragment, memo } from 'react'; +import { useSafeLingui } from '../../../i18n'; +import { SEQ_BAR_HEIGHT } from '../../constants'; +import type { SequenceRow } from '../../utils/sequential/sequential.types'; +import { CanvasInlineButton } from '../ButtonHandle/CanvasInlineButton'; + +/** + * Width of the fixed step-number / chevron column. Every + * row's column sits at the SAME x regardless of its own indent depth -- a + * file-explorer "tree rail" rather than a per-row indented gutter -- so this + * only needs to be wide enough for a chevron (28px) + gap + a couple of digits + * of step number, not tied to `SEQ_INDENT_PX`. + */ +const SEQ_GUTTER_COLUMN_WIDTH_PX = 64; + +/** + * Horizontal breathing room (px) between the fixed column and where a row's + * dotted leader line begins. Also the leader's length for a depth-0 row (whose + * bar sits at the stack's own left edge), so every row -- even top-level ones + * -- gets a short, deliberate connector instead of the column touching the bar. + */ +const SEQ_GUTTER_LEADER_GAP_PX = 16; + +export interface SequentialGutterRow + extends Pick {} + +export interface SequentialGutterProps { + /** + * Visible rows carrying a step number, in row order (the same filter the + * canvas assembly derives once and shares with `useSequentialKeyboard`). + */ + rows: readonly SequentialGutterRow[]; + /** + * Row id -> top-left flow position, read straight from `layoutSequence`'s + * result (never from node DOM/measurement), so offscreen culling or a + * virtualization change upstream can never desync the gutter from the bars + * it labels; it also means the gutter pans/zooms with the canvas for free + * since it renders inside the same `ViewportPortal` coordinate space. + */ + positions: ReadonlyMap; + /** Height of each sequential row; defaults to the standard bar height. */ + barHeight?: number; + collapsedStepIds: ReadonlySet; + /** Fired by a collapsible row's chevron; the caller re-derives `collapsedStepIds`. */ + onToggleCollapse: (nodeId: string, collapsed: boolean) => void; +} + +/** + * Step-number "tree rail" gutter for the Sequential Canvas (D8). A single `ViewportPortal` layer + * (mirrors `components/NodeViewportOverlay.tsx`'s pattern, generalized to many + * rows instead of one node) rendering, per visible numbered row: + * + * - the step number and (for collapsible rows) a chevron disclosure button, + * right-aligned together in ONE fixed-x column shared by EVERY row + * regardless of its own indent depth (`SEQ_GUTTER_COLUMN_WIDTH_PX`, sitting + * `SEQ_GUTTER_LEADER_GAP_PX` left of the stack's own left edge) -- a + * file-explorer style numbering rail rather than a per-row indented gutter; + * - a dotted horizontal leader line from that fixed column to the LEFT EDGE + * of the row's own (depth-indented) bar, so a deeper row's leader is + * visibly longer, communicating depth the same way a file explorer's + * connecting lines do. + * + * The chevron carries `aria-expanded` (ARIA disclosure pattern) and a + * localized `aria-label` -- the row's own accessible name ("Step N of Total: + * Label") lives on the bar itself via `node.ariaLabel`, stamped by + * `useSequentialGraph` (D8), not here; the number span is `aria-hidden` so it + * isn't announced twice. The leader line is purely decorative (`aria-hidden`). + */ +export const SequentialGutter = memo(function SequentialGutter({ + rows, + positions, + barHeight = SEQ_BAR_HEIGHT, + collapsedStepIds, + onToggleCollapse, +}: SequentialGutterProps) { + const { _ } = useSafeLingui(); + + if (rows.length === 0) return null; + + // The stack's own left edge: `layoutSequence` always anchors depth-0 rows at + // x = 0 (utils/sequential/layoutSequence.ts, read-only reference), but this + // reads it from the actual positions rather than assuming it, so a future + // layout change (e.g. a reference chip rendered further left) can't silently + // desync the rail from the bars it labels. + let stackLeftX = 0; + for (const position of positions.values()) { + if (position.x < stackLeftX) stackLeftX = position.x; + } + const columnLeftX = stackLeftX - SEQ_GUTTER_COLUMN_WIDTH_PX - SEQ_GUTTER_LEADER_GAP_PX; + const leaderStartX = stackLeftX - SEQ_GUTTER_LEADER_GAP_PX; + + return ( + +
    + {rows.map((row) => { + const position = positions.get(row.nodeId); + if (!position || row.stepNumber === undefined) return null; + const collapsed = collapsedStepIds.has(row.nodeId); + const rowCenterY = position.y + barHeight / 2; + + return ( + +
    + {row.collapsible && ( + event.stopPropagation()} + onClick={(event) => { + event.stopPropagation(); + onToggleCollapse(row.nodeId, !collapsed); + }} + /> + )} + +
    + + + ); +}); diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialInsertGapContext.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialInsertGapContext.tsx new file mode 100644 index 000000000..effb6e3df --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialInsertGapContext.tsx @@ -0,0 +1,27 @@ +import { createContext, useContext } from 'react'; +import type { InsertionSlot } from '../../utils/sequential/sequential.types'; + +export type SetSequentialInsertGapSlot = (slot: InsertionSlot) => void; + +const NOOP_SET: SetSequentialInsertGapSlot = () => {}; + +const SequentialInsertGapContext = createContext(NOOP_SET); + +/** Provided by `SequentialCanvas.tsx`, which owns the actual state + the auto-clear-on-close effect. */ +export const SequentialInsertGapProvider = SequentialInsertGapContext.Provider; + +/** + * The setter every `useSequentialInsert()` call site's `startInsert` invokes + * to report the slot an Add Node panel just opened for -- both + * `SequentialCanvas.tsx`'s own terminal-placeholder callsite and every + * `SequentialConnectorEdge`'s "+" button are independent hook instances with + * no state of their own, so the slot needs a SHARED sink for the insert + * -preview -gap post-pass to react regardless of which + * affordance started the insert. `SequentialCanvas.tsx` owns clearing it + * (via `usePreviewNode` transitioning closed, covering both cancel and + * commit). A no-op default keeps isolated edge stories/tests (no + * `SequentialCanvas` mounted) working unchanged. + */ +export function useSetSequentialInsertGapSlot(): SetSequentialInsertGapSlot { + return useContext(SequentialInsertGapContext); +} diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialInsertStateContext.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialInsertStateContext.tsx new file mode 100644 index 000000000..f0948df1d --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialInsertStateContext.tsx @@ -0,0 +1,22 @@ +import { createContext, type MutableRefObject, type ReactNode, useContext, useRef } from 'react'; +import type { PendingSequentialInsert } from './edges/sequentialInsert'; + +interface SequentialInsertState { + pending: MutableRefObject; +} + +const SequentialInsertStateContext = createContext(undefined); + +/** Per-canvas insertion state shared by connector buttons and AddNodeManager. */ +export function SequentialInsertStateProvider({ children }: { children: ReactNode }) { + const pending = useRef(undefined); + return ( + + {children} + + ); +} + +export function useOptionalSequentialInsertState(): SequentialInsertState | undefined { + return useContext(SequentialInsertStateContext); +} diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialMoveActionsContext.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialMoveActionsContext.tsx new file mode 100644 index 000000000..b580290c9 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialMoveActionsContext.tsx @@ -0,0 +1,35 @@ +import { createContext, useContext } from 'react'; +import type { InsertionSlot } from '../../utils/sequential/sequential.types'; +import type { SequentialMoveOptions } from './sequentialMoveActions'; + +export interface SequentialMoveActionsContextValue { + /** The four move candidates for `nodeId` (disabled direction => `undefined`). */ + getMoveOptions: (nodeId: string) => SequentialMoveOptions; + /** + * Applies `moveSubtree(projection, nodeId, slot, {nodes, edges})` through + * `onNodesChange`/`onEdgesChange` (D10: the public API stays the standard + * change callbacks, never a parallel mutation channel). A no-op for a + * degenerate/self-targeting slot, matching `moveSubtree`'s own guard + * (an empty `GraphChangeSet`). + */ + commitMove: (nodeId: string, slot: InsertionSlot) => void; + /** Centers the viewport on a node (used by the `goto` reference chip, D9). */ + centerOnNode: (nodeId: string) => void; +} + +const SequentialMoveActionsContext = createContext( + undefined +); + +/** Provided by `SequentialCanvas.tsx`. */ +export const SequentialMoveActionsProvider = SequentialMoveActionsContext.Provider; + +/** + * Returns the current move-actions binding, or `undefined` outside a provider + * (isolated node/edge stories and tests render `SequentialStepNode` / + * `SequentialConnectorEdge` standalone; both must degrade gracefully -- no + * kebab move items, no reference-chip navigation -- rather than throw). + */ +export function useOptionalSequentialMoveActions(): SequentialMoveActionsContextValue | undefined { + return useContext(SequentialMoveActionsContext); +} diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialViewContext.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialViewContext.tsx new file mode 100644 index 000000000..bcd67846a --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialViewContext.tsx @@ -0,0 +1,71 @@ +import type { Viewport } from '@uipath/apollo-react/canvas/xyflow/react'; +import { createContext, type ReactNode, useCallback, useContext, useMemo, useRef } from 'react'; +import type { CanvasView } from '../../utils/sequential/sequential.types'; +import { useCanvasViewMode } from './useCanvasViewMode'; + +/** + * Coordination hub for a flow/sequential toggle composition. It owns the current + * view (persisted via {@link useCanvasViewMode}) and a per-view viewport store so + * switching views restores the viewport you left behind (the HierarchicalCanvas + * save/restore pattern applied to the view axis, D11). + * + * It is OPTIONAL: SequentialCanvas works standalone without it. A consumer that + * wants a real toggle wraps the canvas in {@link SequentialViewProvider} and + * feeds `view` / `onChange` to the canvas and ViewSwitcher. + */ +export interface SequentialViewContextValue { + view: CanvasView; + setView: (view: CanvasView) => void; + /** Remember a view's viewport before switching away. */ + saveViewport: (view: CanvasView, viewport: Viewport) => void; + /** The viewport to restore when a view mounts (undefined = fit fresh). */ + getViewport: (view: CanvasView) => Viewport | undefined; +} + +const SequentialViewContext = createContext(undefined); + +export interface SequentialViewProviderProps { + children: ReactNode; + /** localStorage key backing the persisted view choice. */ + storageKey: string; + initialView?: CanvasView; +} + +export function SequentialViewProvider({ + children, + storageKey, + initialView = 'flow', +}: SequentialViewProviderProps) { + const [view, setView] = useCanvasViewMode(storageKey, initialView); + const viewportByView = useRef>(new Map()); + + const saveViewport = useCallback((forView: CanvasView, viewport: Viewport) => { + viewportByView.current.set(forView, viewport); + }, []); + + const getViewport = useCallback( + (forView: CanvasView): Viewport | undefined => viewportByView.current.get(forView), + [] + ); + + const value = useMemo( + () => ({ view, setView, saveViewport, getViewport }), + [view, setView, saveViewport, getViewport] + ); + + return {children}; +} + +/** Returns the context or throws when used outside a {@link SequentialViewProvider}. */ +export function useSequentialView(): SequentialViewContextValue { + const context = useContext(SequentialViewContext); + if (!context) { + throw new Error('useSequentialView must be used within a SequentialViewProvider'); + } + return context; +} + +/** Returns the context or undefined, so SequentialCanvas can run standalone. */ +export function useOptionalSequentialView(): SequentialViewContextValue | undefined { + return useContext(SequentialViewContext); +} diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/ToggleHarness.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/ToggleHarness.tsx new file mode 100644 index 000000000..e96de55cf --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/ToggleHarness.tsx @@ -0,0 +1,89 @@ +import type { Edge, Node } from '@uipath/apollo-react/canvas/xyflow/react'; +import { applyEdgeChanges, applyNodeChanges } from '@uipath/apollo-react/canvas/xyflow/react'; +import { useCallback, useRef, useState } from 'react'; +import { BaseNode } from '../BaseNode/BaseNode'; +import { prepareCanvasViewTransition } from './prepareCanvasViewTransition'; +import { SequentialCanvas } from './SequentialCanvas'; +import { SequentialViewProvider, useSequentialView } from './SequentialViewContext'; +import { ViewSwitcher } from './ViewSwitcher'; + +export interface ToggleHarnessProps { + initialNodes: Node[]; + initialEdges: Edge[]; + storageKey?: string; + mode?: 'design' | 'view' | 'readonly'; +} + +const FLOW_NODE_TYPES = { default: BaseNode }; + +/** + * Minimal composition demonstrating the flow<->sequential toggle (D11): a single + * canonical nodes/edges state, a ViewSwitcher, and one mounted SequentialCanvas + * that swaps its derived arrays in place. On toggle back to flow, nodes inserted + * while in sequential view are normalized and the canonical graph receives a + * deterministic left-to-right layout through prepareCanvasViewTransition. + * + * Used by tests and stories; not part of the public barrel (D13). + */ +export function ToggleHarness({ + initialNodes, + initialEdges, + storageKey = 'sequential-canvas.toggle-harness', + mode = 'design', +}: ToggleHarnessProps) { + return ( + + + + ); +} + +function ToggleHarnessInner({ + initialNodes, + initialEdges, + mode, +}: Required>) { + const { view, setView } = useSequentialView(); + const [nodes, setNodes] = useState(initialNodes); + const [edges, setEdges] = useState(initialEdges); + + const edgesRef = useRef(edges); + edgesRef.current = edges; + const handleViewChange = useCallback( + (nextView: typeof view) => { + setNodes((current) => prepareCanvasViewTransition(nextView, current, edgesRef.current).nodes); + setView(nextView); + }, + [setView] + ); + + const onNodesChange = useCallback( + (changes: Parameters>[0]) => + setNodes((current) => applyNodeChanges(changes, current)), + [] + ); + const onEdgesChange = useCallback( + (changes: Parameters>[0]) => + setEdges((current) => applyEdgeChanges(changes, current)), + [] + ); + + return ( +
    +
    + +
    +
    + +
    +
    + ); +} diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/ViewSwitcher.test.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/ViewSwitcher.test.tsx new file mode 100644 index 000000000..f461d2f09 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/ViewSwitcher.test.tsx @@ -0,0 +1,15 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { ViewSwitcher } from './ViewSwitcher'; + +describe('ViewSwitcher', () => { + it('renders both view options and calls onChange when the other is picked', () => { + const onChange = vi.fn(); + render(); + + expect(screen.getByRole('radio', { name: 'Flow' })).toBeInTheDocument(); + const sequential = screen.getByRole('radio', { name: 'Sequential' }); + fireEvent.click(sequential); + expect(onChange).toHaveBeenCalledWith('sequential'); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/ViewSwitcher.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/ViewSwitcher.tsx new file mode 100644 index 000000000..27a837f3c --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/ViewSwitcher.tsx @@ -0,0 +1,44 @@ +import { ToggleGroup, ToggleGroupItem } from '@uipath/apollo-wind'; +import { memo } from 'react'; +import { useSafeLingui } from '../../../i18n'; +import { CanvasIcon } from '../../utils/icon-registry'; +import type { CanvasView } from '../../utils/sequential/sequential.types'; +import type { ViewSwitcherProps } from './SequentialCanvas.types'; + +/** + * Segmented flow/sequential control (D11). It is a controlled component: the + * host owns `value` and `onChange` (typically wired to useCanvasViewMode / + * SequentialViewProvider). + */ +function ViewSwitcherComponent({ value, onChange }: ViewSwitcherProps) { + const { _ } = useSafeLingui(); + + const flowLabel = _({ id: 'sequential-canvas.view.flow', message: 'Flow' }); + const sequentialLabel = _({ id: 'sequential-canvas.view.sequential', message: 'Sequential' }); + + return ( +
    + { + // Radix emits '' when the active item is re-pressed; keep the current + // view rather than clearing it (a segmented control is never empty). + if (next === 'flow' || next === 'sequential') onChange(next as CanvasView); + }} + aria-label={_({ id: 'sequential-canvas.view.label', message: 'Canvas view' })} + > + + + {flowLabel} + + + + {sequentialLabel} + + +
    + ); +} + +export const ViewSwitcher = memo(ViewSwitcherComponent); diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialBranchHeader.test.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialBranchHeader.test.tsx new file mode 100644 index 000000000..c92032212 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialBranchHeader.test.tsx @@ -0,0 +1,15 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { SequentialBranchHeader } from './SequentialBranchHeader'; + +describe('SequentialBranchHeader', () => { + it('aligns an edge-styled label immediately above the branch target', () => { + render(); + + const header = screen.getByTestId('sequential-branch-header'); + expect(header).toHaveStyle({ transform: 'translate(128px, 238px) translateY(-100%)' }); + const label = screen.getByText('False'); + expect(label).toHaveClass('react-flow__edge-label'); + expect(label.getAttribute('style')).toContain('var(--canvas-primary)'); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialBranchHeader.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialBranchHeader.tsx new file mode 100644 index 000000000..d2c2ab506 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialBranchHeader.tsx @@ -0,0 +1,37 @@ +import { EdgeLabelRenderer } from '@uipath/apollo-react/canvas/xyflow/react'; +import { EdgeLabelContent } from '../../Edges/shared/primitives'; + +const BRANCH_HEADER_BOTTOM_GAP = 2; + +export interface SequentialBranchHeaderProps { + x: number; + targetTopY: number; + label: string; + selected?: boolean; +} + +/** + * Introduces a branch with the shared edge-label treatment immediately above + * its first row. The header is owned by the branch's content area and therefore + * remains stable when connector routes change. + */ +export function SequentialBranchHeader({ + x, + targetTopY, + label, + selected, +}: SequentialBranchHeaderProps) { + const anchorY = targetTopY - BRANCH_HEADER_BOTTOM_GAP; + + return ( + +
    + +
    +
    + ); +} diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialConnectorEdge.test.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialConnectorEdge.test.tsx new file mode 100644 index 000000000..ec4cacc6a --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialConnectorEdge.test.tsx @@ -0,0 +1,109 @@ +import { render, screen } from '@testing-library/react'; +import { Position } from '@uipath/apollo-react/canvas/xyflow/react'; +import { describe, expect, it } from 'vitest'; +import type { BaseCanvasProps } from '../../BaseCanvas/BaseCanvas.types'; +import { BaseCanvasModeProvider } from '../../BaseCanvas/BaseCanvasModeProvider'; +import { getLeftEntryArrowTargetY, SequentialConnectorEdge } from './SequentialConnectorEdge'; +import type { + SequentialConnectorData, + SequentialConnectorEdgeProps, +} from './SequentialConnectorEdge.types'; + +const baseProps = { + id: 'e1', + source: 'a', + target: 'b', + sourceX: 0, + sourceY: 0, + sourcePosition: Position.Bottom, + targetX: 100, + targetY: 200, + targetPosition: Position.Top, +} as unknown as SequentialConnectorEdgeProps; + +function renderEdge(data: SequentialConnectorData, mode: BaseCanvasProps['mode'] = 'design') { + return render( + + + + + + ); +} + +describe('SequentialConnectorEdge', () => { + describe('goto kind', () => { + it('renders a dashed path', () => { + const { container } = renderEdge({ kind: 'goto' }); + + const visiblePath = container.querySelector('.react-flow__edge-path') as SVGPathElement; + expect(visiblePath.style.strokeDasharray).toBe('5,5'); + }); + }); + + describe('merge-back kind', () => { + it('renders a straight container continuation solid', () => { + const { container } = renderEdge({ + kind: 'merge-back', + waypoints: [], + slot: { id: 'container-continuation' }, + }); + + const visiblePath = container.querySelector('.react-flow__edge-path') as SVGPathElement; + expect(visiblePath.style.strokeDasharray).toBe('0'); + expect(screen.getByRole('button', { name: 'Insert step' }).parentElement).toHaveStyle({ + transform: 'translate(-50%, -50%) translate(100px, 180px)', + }); + }); + + it('renders an elbowed branch rejoin solid', () => { + const { container } = renderEdge({ + kind: 'merge-back', + waypoints: [ + { x: 0, y: 100 }, + { x: 100, y: 100 }, + ], + }); + + const visiblePath = container.querySelector('.react-flow__edge-path') as SVGPathElement; + expect(visiblePath.style.strokeDasharray).toBe('0'); + }); + }); + + it('centers a left-entry arrow using the target node height', () => { + expect(getLeftEntryArrowTargetY(200, 999, 48)).toBe(224); + expect(getLeftEntryArrowTargetY(undefined, 200, undefined)).toBe(228); + }); + + describe('step kind insert affordance', () => { + it('renders the insert button when a slot is present and the canvas is in design mode', () => { + renderEdge({ kind: 'step', slot: { id: 's-1', graphEdgeId: 'e1' } }, 'design'); + const button = screen.getByRole('button', { name: 'Insert step' }); + expect(button).toBeInTheDocument(); + // Faint at rest (discoverable without hover), full on hover/focus. + expect(button).toHaveClass( + 'opacity-40', + 'group-hover:opacity-100', + 'group-focus-within:opacity-100' + ); + }); + + it('does not render the insert button outside design mode, even with a slot', () => { + renderEdge({ kind: 'step', slot: { id: 's-1', graphEdgeId: 'e1' } }, 'view'); + expect(screen.queryByRole('button', { name: 'Insert step' })).not.toBeInTheDocument(); + }); + + it('does not render the insert button in design mode without a slot', () => { + renderEdge({ kind: 'step' }, 'design'); + expect(screen.queryByRole('button', { name: 'Insert step' })).not.toBeInTheDocument(); + }); + }); + + it('renders a branch-entry label above the row using the shared edge-label style', () => { + const { container } = renderEdge({ kind: 'branch-entry', label: 'True' }); + + expect(screen.getByTestId('sequential-branch-header')).toHaveTextContent('True'); + expect(container.querySelector('.react-flow__edge-label')).toBeInTheDocument(); + expect(container.querySelector('foreignObject')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialConnectorEdge.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialConnectorEdge.tsx new file mode 100644 index 000000000..fd8ac7f50 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialConnectorEdge.tsx @@ -0,0 +1,285 @@ +import { Position, useStore } from '@uipath/apollo-react/canvas/xyflow/react'; +import { memo, useCallback, useState } from 'react'; +import { useSafeLingui } from '../../../../i18n'; +import { SEQ_BAR_HEIGHT, SEQ_EDGE_CORNER_RADIUS } from '../../../constants'; +import { useBaseCanvasMode } from '../../BaseCanvas/BaseCanvasModeProvider'; +import { areEdgePropsEqual } from '../../Edges/shared/areEdgePropsEqual'; +import { EMPTY_WAYPOINTS } from '../../Edges/shared/constants'; +import { useEdgeGeometry, useExecutionEdge } from '../../Edges/shared/hooks'; +import { EdgeArrow, EdgeLabel, EdgePath } from '../../Edges/shared/primitives'; +import { resolveEdgeColor } from '../../Edges/shared/resolveEdgeColor'; +import { SequentialBranchHeader } from './SequentialBranchHeader'; +import type { SequentialConnectorEdgeProps } from './SequentialConnectorEdge.types'; +import { SequentialInsertButton } from './SequentialInsertButton'; +import { resolveConnectorStrokeStyle } from './sequentialConnectorStyle'; +import { useSequentialInsert } from './useSequentialInsert'; + +/** + * Vertical offset (flow px) between a NON-branch connector's label pill and its + * centered ⊕ (the ⊕ owns the connector's true midpoint; the label is nudged up). + * Branch-entry connectors instead split the two onto separate segments of their + * elbow (label on the vertical spine, ⊕ on the horizontal jog) so they never + * crowd - see the branch placement below. + */ +const INSERT_BUTTON_LABEL_OFFSET_PX = 26; + +/** + * Sequential arrowhead placement. Flow-view edges translate the arrow + * ARROW_OFFSET px INTO the target node so the node paints over the tuck; + * sequential bars are opaque and connectors render behind them, so a big tuck is + * clipped. The tip is anchored to the target bar's real top edge (read from the + * store, see `targetTopY`); this 1px nudge lets it bite just into the bar so it + * reads as connected rather than leaving a hairline gap, with the body in the + * clear row gap above it so it stays fully visible without elevating the edge. + */ +/** + * Vertical offset (flow px) that anchors a merge-back connector's insert plus + * to an endpoint instead of the line midpoint. A merge-back's midpoint drifts + * onto whichever row sits alongside it (e.g. a collapsed child), colliding with + * that row's own add points; anchoring near an endpoint reads as "add right + * here" and never lands on an unrelated row. + */ +const MERGE_BACK_INSERT_OFFSET_PX = 20; + +const ARROW_TIP_AT_BAR_EDGE = { x: 0, y: 1 } as const; +/** Same 1px bite, but into the LEFT face for branch-entry (mid-left) arrows. */ +const ARROW_TIP_AT_BAR_LEFT_EDGE = { x: 1, y: 0 } as const; + +export function getLeftEntryArrowTargetY( + targetTopY: number | undefined, + targetY: number, + targetHeight: number | undefined +): number { + return (targetTopY ?? targetY) + (targetHeight ?? SEQ_BAR_HEIGHT) / 2; +} + +/** + * Sequential-view connector edge. Composes the shared edge primitives + * (EdgePath / EdgeArrow / EdgeLabel) and renders behavior from `data`: + * + * - stroke style by kind (ordinary structural flow solid; irregular `goto` + * references and transient previews dashed) via {@link resolveConnectorStrokeStyle}; + * - an edge-styled label above the first row of each labeled branch; + * - `data.waypoints` rendered verbatim (elbows supplied by layoutSequence; no + * router, no waypoint editing); + * - a statically centered ⊕ insert affordance, revealed on hover/focus and + * available only in design mode when `data.slot` is present. + * + * See ./SequentialConnectorEdge.types.ts for the field-by-field `data` contract + * the assembly builds. `data` is compared by reference (areEdgePropsEqual), so the assembly must + * keep it reference-stable across data-only changes (D12). + */ +export const SequentialConnectorEdge = memo(function SequentialConnectorEdge({ + id, + selected, + source, + target, + sourceX, + sourceY, + sourcePosition = Position.Bottom, + targetX, + targetY, + targetPosition = Position.Top, + sourceHandleId, + targetHandleId, + style, + data, +}: SequentialConnectorEdgeProps) { + const { _ } = useSafeLingui(); + const isDesignMode = useBaseCanvasMode().mode === 'design'; + const { startInsert } = useSequentialInsert(); + + // Real top-left corner of the target bar. The handle-derived `targetX/Y` sit + // slightly off the bar's painted edge, so anchoring the arrowhead to the + // node's absolute position lands the tip exactly on the bar — its top edge for + // a normal (Top) entry, its left edge at mid-height for a branch (Left) entry. + const targetTopY = useStore((s) => s.nodeLookup.get(target)?.internals.positionAbsolute.y); + const targetLeftX = useStore((s) => s.nodeLookup.get(target)?.internals.positionAbsolute.x); + const targetHeight = useStore((s) => { + const targetNode = s.nodeLookup.get(target); + // Sequential rows publish their layout-owned height immediately. Prefer it + // over the measured value, which can trail a slider update by one render. + return targetNode?.height ?? targetNode?.measured?.height; + }); + // Branch-entry connectors carry a Left target handle, so React Flow reports + // Position.Left here — the arrow then points right into the bar's mid-left. + const isLeftEntry = targetPosition === Position.Left; + + const [isHovered, setIsHovered] = useState(false); + const onMouseEnter = useCallback(() => setIsHovered(true), []); + const onMouseLeave = useCallback(() => setIsHovered(false), []); + + const kind = data?.kind ?? 'step'; + const label = data?.label; + const slot = data?.slot; + const hideArrowHead = !!data?.hideArrowHead; + const isPreview = !!data?.preview; + const waypoints = data?.waypoints ?? EMPTY_WAYPOINTS; + + // Waypoint routing is used for every kind: elbowed connectors carry explicit + // waypoints, and straight step connectors get a clean orthogonal path between + // the two anchors from an empty waypoint list. + const geometry = useEdgeGeometry({ + routing: 'waypoint', + sourceNodeId: source, + targetNodeId: target, + sourceHandleId, + targetHandleId, + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + waypoints, + enableSegments: false, + hideArrowHead, + // Pronounced smooth-step corners for the sequential view: the elbowed + // branch/merge-back connectors read as soft rounded steps rather than the + // subtler default. createRoundedPath clamps this to half the shorter + // adjacent segment, so tight jogs degrade gracefully. + borderRadius: SEQ_EDGE_CORNER_RADIUS, + }); + + const execution = useExecutionEdge({ + edgeId: id, + target, + edgePath: geometry.edgePath, + enabled: !!data?.enableExecution, + }); + + const color = resolveEdgeColor({ + selected, + isHovered, + previewEdge: isPreview, + statusColor: execution.statusColor, + }); + + // Merge-backs are ordinary structural flow regardless of whether they are a + // straight container continuation or an elbowed branch rejoin. Only + // irregular goto references and transient previews use a dashed stroke. + const strokeStyle = isPreview ? 'dashed' : resolveConnectorStrokeStyle(kind); + const showInsert = isDesignMode && !!slot; + const hasLabel = typeof label === 'string' && label.length > 0; + + // Branch-entry connectors elbow: a vertical spine drop from the owner then a + // horizontal jog into the target (waypoints [spineBottom, jogEnd] from + // layoutSequence). The ⊕ stays on that horizontal jog. Its label is rendered + // separately above the target row, so labels no longer + // compete with connector geometry or one another. + const wp0 = waypoints[0]; + const wp1 = waypoints[1]; + const isBranchElbow = kind === 'branch-entry' && wp0 !== undefined && wp1 !== undefined; + // Merge-back placement: a straight merge-back (a container's continuation, + // no waypoints) anchors just ABOVE its target, so "add after the container" + // sits after the whole body rather than in the middle of it. An elbowed + // merge-back (a branch lane rejoining the flow) anchors just BELOW its source, + // so "add at the end of this branch" sits under the branch's last step. + const insertPoint = isBranchElbow + ? { x: (wp0.x + wp1.x) / 2, y: wp0.y } + : kind === 'merge-back' + ? waypoints.length === 0 + ? { x: targetX, y: targetY - MERGE_BACK_INSERT_OFFSET_PX } + : { x: sourceX, y: sourceY + MERGE_BACK_INSERT_OFFSET_PX } + : geometry.labelPoint; + const labelPoint = showInsert + ? { x: geometry.labelPoint.x, y: geometry.labelPoint.y - INSERT_BUTTON_LABEL_OFFSET_PX } + : geometry.labelPoint; + const resolvedTargetHeight = typeof targetHeight === 'number' ? targetHeight : SEQ_BAR_HEIGHT; + const resolvedTargetTopY = + typeof targetTopY === 'number' + ? targetTopY + : isLeftEntry + ? targetY - resolvedTargetHeight / 2 + : targetY; + const resolvedTargetLeftX = typeof targetLeftX === 'number' ? targetLeftX : targetX; + + const onInsert = useCallback(() => { + if (!slot) return; + startInsert({ + slot, + source, + sourceHandleId, + target, + targetHandleId, + sourcePosition, + position: { x: (sourceX + targetX) / 2, y: (sourceY + targetY) / 2 }, + // This edge's OWN rendered id — the store in sequential view holds + // derived connector edges, never canonical ids, so the split connector + // must be found (and stashed for cancel-restore) by this id, not + // slot.graphEdgeId. + connectorEdgeId: id, + }); + }, [ + slot, + source, + sourceHandleId, + target, + targetHandleId, + sourcePosition, + sourceX, + sourceY, + targetX, + targetY, + startInsert, + id, + ]); + + const opacity = (style?.opacity as number | undefined) ?? 1; + + return ( + <> + + + + {!hideArrowHead && ( + + )} + + {execution.animation} + + {hasLabel && kind !== 'branch-entry' && ( + + )} + + + {hasLabel && kind === 'branch-entry' && ( + + )} + + {showInsert && ( + + )} + + ); +}, areEdgePropsEqual); diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialConnectorEdge.types.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialConnectorEdge.types.ts new file mode 100644 index 000000000..9cb86bc2f --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialConnectorEdge.types.ts @@ -0,0 +1,69 @@ +import type { Edge, EdgeProps } from '@uipath/apollo-react/canvas/xyflow/react'; +import type { + InsertionSlot, + SequenceConnectorKind, +} from '../../../utils/sequential/sequential.types'; +import type { Waypoint } from '../../Edges/shared/types'; + +/** + * Reference-stable `data` payload for a sequential connector edge. + * + * The assembly builds one of these per {@link SequenceConnector} when it derives the + * xyflow edge array (see useSequentialGraph). The object MUST be built only on + * structural change and reused by reference otherwise, so `areEdgePropsEqual` + * (which compares `data` by reference, see components/Edges/shared/areEdgePropsEqual.ts) + * can short-circuit the memo during pan/zoom and data-only edits (D12). + * + * Field-by-field contract: + * - `kind` copy from `connector.kind`. Drives stroke style: `step` + * `branch-entry`, and `merge-back` render solid; only + * irregular `goto` references render dashed. See + * {@link resolveConnectorStrokeStyle}. + * - `label` copy from `connector.label`. Branch-entry labels render + * above their first row using the shared edge-label style; + * other kinds + * retain the centered edge pill. Omit or use an empty + * string to render no label. + * - `waypoints` elbow geometry from `layoutSequence().connectorWaypoints` + * keyed by the connector id. Rendered verbatim (no router, + * no editing). When absent, the edge draws a clean + * orthogonal path between the two handle anchors. Reuse the + * SAME array reference across renders; a fresh `[]` each + * render defeats geometry memoization (prefer the shared + * EMPTY_WAYPOINTS fallback). + * - `slot` copy from `connector.slot`. The centered insert (+) + * button renders IFF this is present AND the canvas is in + * design mode. `goto` connectors carry no slot and so get + * no button. + * - `enableExecution` opt in to id-keyed execution coloring + the in-progress + * dot, via the existing ExecutionStatusContext. Defaults to + * off; turning it on matches CanvasEdge behavior. + * - `hideArrowHead` suppress the arrow head. Defaults to showing an arrow + * pointing into the target for every kind. + */ +// A `type` (not an `interface`) so it implicitly satisfies xyflow's +// `Edge>` constraint, matching CanvasEdgeData. +export type SequentialConnectorData = { + kind: SequenceConnectorKind; + label?: string | null; + waypoints?: Waypoint[]; + slot?: InsertionSlot; + /** Transient Add Node connector: selected color + dashed preview stroke. */ + preview?: boolean; + /** Visual-only preview join that AddNodeManager must not materialize. */ + ignorePreviewConnection?: boolean; + /** + * Canonical manifest handle on the existing endpoint of a preview edge. + * The rendered edge stays on the guaranteed sequential bar handle while + * AddNodeManager uses this id for connection filtering and materialization. + */ + previewConnectionHandleId?: string; + enableExecution?: boolean; + hideArrowHead?: boolean; +}; + +/** The xyflow edge shape rendered by {@link SequentialConnectorEdge}. */ +export type SequentialConnectorEdgeType = Edge; + +/** Props xyflow hands the edge component. */ +export type SequentialConnectorEdgeProps = EdgeProps; diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialInsertButton.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialInsertButton.tsx new file mode 100644 index 000000000..d7473bf24 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialInsertButton.tsx @@ -0,0 +1,59 @@ +import { EdgeLabelRenderer, type XYPosition } from '@uipath/apollo-react/canvas/xyflow/react'; +import { type MouseEvent as ReactMouseEvent, useCallback } from 'react'; +import { CanvasInlineButton } from '../../ButtonHandle/CanvasInlineButton'; + +export interface SequentialInsertButtonProps { + /** Center point in flow coordinates (the connector midpoint). */ + point: XYPosition; + /** Accessible label, already localized. */ + label: string; + /** Invoked on click; opens the Add Node panel for the connector's slot. */ + onInsert: () => void; +} + +/** + * Statically centered ⊕ affordance for a sequential connector (design mode + * only; the caller gates on slot presence + mode). It rests at a low opacity so + * every insertable connector shows a quiet ⊕, then brightens to full on + * hover/focus. This keeps add points discoverable without a wall of + * full-strength buttons when several nested slots are nearby, and pairs with the + * plus variant of SequentialPlaceholderNode so "add a step" looks identical + * everywhere. Unlike the + * hover-following EdgeToolbar (see Toolbar/EdgeToolbar/useEdgeToolbarPositioning.ts), + * this sits at a fixed point on the connector and never tracks the pointer. + * + * It stops mousedown/click propagation so opening the Add Node panel does not + * trip the Toolbox's outside-mousedown close (components/Toolbox/Toolbox.tsx:621-625). + */ +export function SequentialInsertButton({ point, label, onInsert }: SequentialInsertButtonProps) { + const stopMouseDown = useCallback((event: ReactMouseEvent) => { + event.stopPropagation(); + }, []); + + const handleClick = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation(); + event.preventDefault(); + onInsert(); + }, + [onInsert] + ); + + return ( + +
    + +
    +
    + ); +} diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/index.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/index.ts new file mode 100644 index 000000000..9443e7101 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/index.ts @@ -0,0 +1,24 @@ +// Local barrel for the sequential connector + insert pipeline. This is NOT +// re-exported from components/index.ts; the feature stays out of the public +// canvas barrel until GA (D13). Types first, component/helpers after. + +export { SequentialBranchHeader } from './SequentialBranchHeader'; +export { SequentialConnectorEdge } from './SequentialConnectorEdge'; +export type { + SequentialConnectorData, + SequentialConnectorEdgeProps, + SequentialConnectorEdgeType, +} from './SequentialConnectorEdge.types'; +export type { SequentialInsertButtonProps } from './SequentialInsertButton'; +export { SequentialInsertButton } from './SequentialInsertButton'; +export { resolveConnectorStrokeStyle } from './sequentialConnectorStyle'; +export type { SequentialInsertArgs } from './sequentialInsert'; +export { + buildSequentialPreviewOptions, + getSequentialIgnoredNodeTypes, + SEQ_INSERTED_FLAG, + SEQUENTIAL_IGNORED_NODE_TYPES, + sequentialOnBeforeNodeAdded, +} from './sequentialInsert'; +export type { UseSequentialInsertResult } from './useSequentialInsert'; +export { useSequentialInsert } from './useSequentialInsert'; diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/sequentialConnectorStyle.test.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/sequentialConnectorStyle.test.ts new file mode 100644 index 000000000..4dc52748d --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/sequentialConnectorStyle.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import type { SequenceConnectorKind } from '../../../utils/sequential/sequential.types'; +import { resolveConnectorStrokeStyle } from './sequentialConnectorStyle'; + +describe('resolveConnectorStrokeStyle', () => { + it('renders structural flow connectors solid', () => { + expect(resolveConnectorStrokeStyle('step')).toBe('solid'); + expect(resolveConnectorStrokeStyle('branch-entry')).toBe('solid'); + expect(resolveConnectorStrokeStyle('merge-back')).toBe('solid'); + }); + + it('renders irregular references dashed', () => { + expect(resolveConnectorStrokeStyle('goto')).toBe('dashed'); + }); + + it('covers every connector kind', () => { + const kinds: SequenceConnectorKind[] = ['step', 'branch-entry', 'merge-back', 'goto']; + for (const kind of kinds) { + expect(['solid', 'dashed']).toContain(resolveConnectorStrokeStyle(kind)); + } + }); +}); diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/sequentialConnectorStyle.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/sequentialConnectorStyle.ts new file mode 100644 index 000000000..b1ba9d2b6 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/sequentialConnectorStyle.ts @@ -0,0 +1,15 @@ +import type { SequenceConnectorKind } from '../../../utils/sequential/sequential.types'; +import type { EdgeStrokeStyle } from '../../Edges/shared/types'; + +/** + * Maps a connector kind to its stroke style. + * + * - `step` / `branch-entry` / `merge-back` are all structural control flow: + * solid. + * - `goto` is an irregular reference (cycle or already-visited target): dashed. + * - transient preview connectors are handled by SequentialConnectorEdge and + * remain dashed independently of this mapping. + */ +export function resolveConnectorStrokeStyle(kind: SequenceConnectorKind): EdgeStrokeStyle { + return kind === 'goto' ? 'dashed' : 'solid'; +} diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/sequentialInsert.test.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/sequentialInsert.test.ts new file mode 100644 index 000000000..267ee1a64 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/sequentialInsert.test.ts @@ -0,0 +1,450 @@ +import type { Edge, Node, ReactFlowInstance } from '@uipath/apollo-react/canvas/xyflow/react'; +import { Position } from '@uipath/apollo-react/canvas/xyflow/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + DEFAULT_SOURCE_HANDLE_ID, + DEFAULT_TARGET_HANDLE_ID, + SEQ_BAR_HEIGHT, + SEQ_BAR_WIDTH, + SEQ_PLACEHOLDER_NODE_TYPE, + SEQ_START_NODE_TYPE, +} from '../../../constants'; +import { SEQ_CONTINUATION_EDGE_KEY } from '../../../utils/sequential/graph-helpers'; +import type { InsertionSlot } from '../../../utils/sequential/sequential.types'; +import { SEQUENTIAL_BAR_HANDLE_IDS } from '../nodes'; +import { + buildSequentialPreviewOptions, + getSequentialIgnoredNodeTypes, + type PendingSequentialInsert, + resolvePendingSequentialInsert, + SEQ_INSERTED_FLAG, + SEQUENTIAL_IGNORED_NODE_TYPES, + sequentialOnBeforeNodeAdded, +} from './sequentialInsert'; + +const FAKE_UUID = '00000000-0000-0000-0000-000000000001'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('SEQUENTIAL_IGNORED_NODE_TYPES', () => { + it('lists exactly the synthetic sequential row types (clones keep real types)', () => { + expect(SEQUENTIAL_IGNORED_NODE_TYPES).toEqual([SEQ_START_NODE_TYPE, SEQ_PLACEHOLDER_NODE_TYPE]); + }); + + it('merges host extras into a fresh array', () => { + const merged = getSequentialIgnoredNodeTypes(['sticky']); + expect(merged).toEqual([...SEQUENTIAL_IGNORED_NODE_TYPES, 'sticky']); + expect(merged).not.toBe(SEQUENTIAL_IGNORED_NODE_TYPES); + }); +}); + +describe('sequentialOnBeforeNodeAdded', () => { + const previousId = 'uipath.slack-1720000000000'; + + const seedNode = (): Node => + ({ + id: previousId, + type: 'uipath.slack', + position: { x: 420, y: 96 }, + data: { label: 'Slack', display: { label: 'Send message' } }, + }) as Node; + + const seedEdges = (): Edge[] => [ + { + id: `edge_srcA-output-${previousId}-input`, + source: 'srcA', + sourceHandle: 'output', + target: previousId, + targetHandle: 'input', + type: 'default', + } as Edge, + { + id: `edge_${previousId}-output-tgtB-input`, + source: previousId, + sourceHandle: 'output', + target: 'tgtB', + targetHandle: 'input', + type: 'default', + } as Edge, + ]; + + it('re-ids the node with crypto.randomUUID()', () => { + const spy = vi.spyOn(crypto, 'randomUUID').mockReturnValue(FAKE_UUID); + const { newNode } = sequentialOnBeforeNodeAdded(seedNode(), seedEdges()); + expect(spy).toHaveBeenCalledTimes(1); + expect(newNode.id).toBe(FAKE_UUID); + }); + + it('keeps the preview-derived position and does not force draggable false', () => { + // The sequential clone derivation (useSequentialGraph) already stamps + // draggable:false on every row unconditionally, so onBeforeNodeAdded must + // not also write it into canonical state, or it leaks permanently once the + // user toggles back to flow view (synthesizePositionsForFlow only clears it + // on that boundary; nothing clears it while still in sequential view). + vi.spyOn(crypto, 'randomUUID').mockReturnValue(FAKE_UUID); + const seed = seedNode(); + const { newNode } = sequentialOnBeforeNodeAdded(seed, seedEdges()); + expect(newNode.position).toEqual(seed.position); + expect(newNode.draggable).toBeUndefined(); + }); + + it('stamps seqInserted while preserving existing data and type', () => { + vi.spyOn(crypto, 'randomUUID').mockReturnValue(FAKE_UUID); + const { newNode } = sequentialOnBeforeNodeAdded(seedNode(), seedEdges()); + expect(newNode.type).toBe('uipath.slack'); + expect((newNode.data as Record)[SEQ_INSERTED_FLAG]).toBe(true); + expect((newNode.data as Record).label).toBe('Slack'); + }); + + it('rewrites edges that reference the seed id and regenerates their ids', () => { + vi.spyOn(crypto, 'randomUUID').mockReturnValue(FAKE_UUID); + const { newEdges } = sequentialOnBeforeNodeAdded(seedNode(), seedEdges()); + + const incoming = newEdges.find((edge) => edge.source === 'srcA'); + const outgoing = newEdges.find((edge) => edge.target === 'tgtB'); + + expect(incoming?.target).toBe(FAKE_UUID); + expect(incoming?.id).toBe(`edge_srcA-output-${FAKE_UUID}-input`); + expect(outgoing?.source).toBe(FAKE_UUID); + expect(outgoing?.id).toBe(`edge_${FAKE_UUID}-output-tgtB-input`); + }); + + it('leaves an unrelated edge untouched by reference', () => { + vi.spyOn(crypto, 'randomUUID').mockReturnValue(FAKE_UUID); + const unrelated = { + id: 'edge_x-output-y-input', + source: 'x', + target: 'y', + type: 'default', + } as Edge; + const { newEdges } = sequentialOnBeforeNodeAdded(seedNode(), [unrelated]); + expect(newEdges[0]).toBe(unrelated); + }); + + describe('with a pending slot (canonical handle/containment rewriting)', () => { + const barEdges = (): Edge[] => [ + { + id: `edge_srcA-${SEQUENTIAL_BAR_HANDLE_IDS.source}-${previousId}-${SEQUENTIAL_BAR_HANDLE_IDS.target}`, + source: 'srcA', + sourceHandle: SEQUENTIAL_BAR_HANDLE_IDS.source, + target: previousId, + targetHandle: SEQUENTIAL_BAR_HANDLE_IDS.target, + type: 'default', + } as Edge, + { + id: `edge_${previousId}-${SEQUENTIAL_BAR_HANDLE_IDS.source}-tgtB-${SEQUENTIAL_BAR_HANDLE_IDS.target}`, + source: previousId, + sourceHandle: SEQUENTIAL_BAR_HANDLE_IDS.source, + target: 'tgtB', + targetHandle: SEQUENTIAL_BAR_HANDLE_IDS.target, + type: 'default', + } as Edge, + ]; + + it('restores only the original nodes canonical handles', () => { + vi.spyOn(crypto, 'randomUUID').mockReturnValue(FAKE_UUID); + const pending: PendingSequentialInsert = { + sourceNodeId: 'srcA', + targetNodeId: 'tgtB', + sourceHandleId: 'true', + targetHandleId: 'input', + }; + const { newEdges } = sequentialOnBeforeNodeAdded(seedNode(), barEdges(), pending); + + const incoming = newEdges.find((edge) => edge.source === 'srcA'); + const outgoing = newEdges.find((edge) => edge.target === 'tgtB'); + + expect(incoming?.sourceHandle).toBe('true'); + expect(outgoing?.targetHandle).toBe('input'); + // The inserted node's own handles are not replaced with handles that + // belong to the original source/target nodes. + expect(incoming?.targetHandle).toBe(SEQUENTIAL_BAR_HANDLE_IDS.target); + expect(outgoing?.sourceHandle).toBe(SEQUENTIAL_BAR_HANDLE_IDS.source); + }); + + it('restores implicit defaults only on the original node endpoints', () => { + vi.spyOn(crypto, 'randomUUID').mockReturnValue(FAKE_UUID); + const { newEdges } = sequentialOnBeforeNodeAdded(seedNode(), barEdges(), { + sourceNodeId: 'srcA', + targetNodeId: 'tgtB', + }); + + const incoming = newEdges.find((edge) => edge.source === 'srcA'); + const outgoing = newEdges.find((edge) => edge.target === 'tgtB'); + + expect(incoming?.sourceHandle).toBeUndefined(); + expect(outgoing?.targetHandle).toBeUndefined(); + expect(incoming?.targetHandle).toBe(SEQUENTIAL_BAR_HANDLE_IDS.target); + expect(outgoing?.sourceHandle).toBe(SEQUENTIAL_BAR_HANDLE_IDS.source); + }); + + it('regenerates the edge id from the rewritten handles so the id matches the fields', () => { + vi.spyOn(crypto, 'randomUUID').mockReturnValue(FAKE_UUID); + const pending: PendingSequentialInsert = { + sourceNodeId: 'srcA', + targetNodeId: 'tgtB', + sourceHandleId: 'true', + targetHandleId: 'input', + }; + const { newEdges } = sequentialOnBeforeNodeAdded(seedNode(), barEdges(), pending); + const outgoing = newEdges.find((edge) => edge.source === FAKE_UUID); + expect(outgoing?.id).toBe(`edge_${FAKE_UUID}-${SEQUENTIAL_BAR_HANDLE_IDS.source}-tgtB-input`); + }); + + it('tags both split halves with the exact canonical edge id', () => { + vi.spyOn(crypto, 'randomUUID').mockReturnValue(FAKE_UUID); + const { newEdges } = sequentialOnBeforeNodeAdded(seedNode(), seedEdges(), { + sourceNodeId: 'srcA', + targetNodeId: 'tgtB', + graphEdgeId: 'canonical-edge-7', + sourceHandleId: 'output', + targetHandleId: 'input', + }); + + expect(newEdges).toHaveLength(2); + for (const edge of newEdges) { + expect((edge.data as Record).__sequentialSplitEdgeId).toBe( + 'canonical-edge-7' + ); + } + }); + + it('marks the inserted-node-to-downstream half as a continuation', () => { + vi.spyOn(crypto, 'randomUUID').mockReturnValue(FAKE_UUID); + const { newEdges } = sequentialOnBeforeNodeAdded(seedNode(), seedEdges(), { + sourceNodeId: 'srcA', + targetNodeId: 'tgtB', + graphEdgeId: 'canonical-edge-7', + }); + + const incoming = newEdges.find((edge) => edge.target === FAKE_UUID); + const outgoing = newEdges.find((edge) => edge.source === FAKE_UUID); + expect( + (incoming?.data as Record)[SEQ_CONTINUATION_EDGE_KEY] + ).toBeUndefined(); + expect((outgoing?.data as Record)[SEQ_CONTINUATION_EDGE_KEY]).toBe(true); + }); + + it('preserves an existing continuation marker on the source half when splitting it again', () => { + vi.spyOn(crypto, 'randomUUID').mockReturnValue(FAKE_UUID); + const { newEdges } = sequentialOnBeforeNodeAdded(seedNode(), seedEdges(), { + sourceNodeId: 'srcA', + targetNodeId: 'tgtB', + graphEdgeId: 'canonical-edge-7', + splitEdgeWasContinuation: true, + }); + + for (const edge of newEdges) { + expect((edge.data as Record)[SEQ_CONTINUATION_EDGE_KEY]).toBe(true); + } + }); + + it('leaves non-bar handle ids untouched even when a pending value is present', () => { + vi.spyOn(crypto, 'randomUUID').mockReturnValue(FAKE_UUID); + const pending: PendingSequentialInsert = { + sourceNodeId: 'srcA', + targetNodeId: 'tgtB', + sourceHandleId: 'ignored', + targetHandleId: 'ignored', + }; + const { newEdges } = sequentialOnBeforeNodeAdded(seedNode(), seedEdges(), pending); + const incoming = newEdges.find((edge) => edge.source === 'srcA'); + const outgoing = newEdges.find((edge) => edge.target === 'tgtB'); + expect(incoming?.sourceHandle).toBe('ignored'); + expect(outgoing?.targetHandle).toBe('ignored'); + expect(incoming?.targetHandle).toBe('input'); + expect(outgoing?.sourceHandle).toBe('output'); + }); + + it('applies the slot containerId as canonical parentId/extent on the materialized node', () => { + vi.spyOn(crypto, 'randomUUID').mockReturnValue(FAKE_UUID); + const { newNode } = sequentialOnBeforeNodeAdded(seedNode(), seedEdges(), { + containerId: 'loop-1', + }); + expect(newNode.parentId).toBe('loop-1'); + expect(newNode.extent).toBe('parent'); + }); + + it('omits parentId/extent when the slot has no container', () => { + vi.spyOn(crypto, 'randomUUID').mockReturnValue(FAKE_UUID); + const { newNode } = sequentialOnBeforeNodeAdded(seedNode(), seedEdges(), {}); + expect(newNode.parentId).toBeUndefined(); + expect(newNode.extent).toBeUndefined(); + }); + }); +}); + +describe('resolvePendingSequentialInsert', () => { + it('captures the slot canonical handle ids and containerId', () => { + const slot: InsertionSlot = { + id: 'slot-1', + source: { nodeId: 'a', handleId: 'true' }, + target: { nodeId: 'b', handleId: 'input' }, + containerId: 'loop-1', + }; + expect(resolvePendingSequentialInsert(slot)).toEqual({ + sourceNodeId: 'a', + targetNodeId: 'b', + graphEdgeId: undefined, + sourceHandleId: 'true', + targetHandleId: 'input', + containerId: 'loop-1', + splitEdgeWasContinuation: undefined, + }); + }); + + it('captures continuation semantics when inserting into an already-healed spine', () => { + const pending = resolvePendingSequentialInsert({ + id: 'slot-1', + source: { nodeId: 'a' }, + target: { nodeId: 'b' }, + continuation: true, + }); + expect(pending.splitEdgeWasContinuation).toBe(true); + }); + + it('yields undefined handle ids (never a bar handle) when the slot endpoint omits one', () => { + const slot: InsertionSlot = { id: 'slot-1', source: { nodeId: 'a' }, target: { nodeId: 'b' } }; + const pending = resolvePendingSequentialInsert(slot); + expect(pending.sourceHandleId).toBeUndefined(); + expect(pending.targetHandleId).toBeUndefined(); + }); + + it('yields no containerId when the slot is not scoped to a container', () => { + const slot: InsertionSlot = { id: 'slot-1' }; + expect(resolvePendingSequentialInsert(slot).containerId).toBeUndefined(); + }); +}); + +describe('buildSequentialPreviewOptions', () => { + // The connector's rendered id deliberately DIFFERS from the canonical edge id + // it wraps (as the projection's `conn:${edge.id}` scheme actually produces), proving the + // lookup uses connectorEdgeId (the rendered id) and not slot.graphEdgeId (the + // canonical id) — those live in different id spaces in sequential view, since + // the store here only ever holds derived connector edges. + const connectorEdge = { + id: 'conn:e-1', + source: 'row-a', + sourceHandle: SEQUENTIAL_BAR_HANDLE_IDS.source, + target: 'row-b', + targetHandle: SEQUENTIAL_BAR_HANDLE_IDS.target, + type: 'sequentialConnector', + } as Edge; + + const makeInstance = (edges: Edge[] = [connectorEdge]) => + ({ getEdges: () => edges }) as unknown as ReactFlowInstance; + + const baseArgs = { + source: 'a', + sourceHandleId: SEQUENTIAL_BAR_HANDLE_IDS.source, + target: 'b', + targetHandleId: SEQUENTIAL_BAR_HANDLE_IDS.target, + sourcePosition: Position.Bottom, + position: { x: 100, y: 200 }, + }; + + it('rides the existing pipeline with bar-sized preview + ignored sequential types', () => { + const slot: InsertionSlot = { id: 'slot-1', graphEdgeId: 'e-1' }; + const options = buildSequentialPreviewOptions(makeInstance(), { + ...baseArgs, + slot, + connectorEdgeId: 'conn:e-1', + }); + + expect(options.previewNodeSize).toEqual({ width: SEQ_BAR_WIDTH, height: SEQ_BAR_HEIGHT }); + expect(options.ignoredNodeTypes).toBe(SEQUENTIAL_IGNORED_NODE_TYPES); + expect(options.positionMode).toBe('drop'); + expect(options.sourceHandleType).toBe('source'); + expect(options.handlePosition).toBe(Position.Bottom); + expect(options.position).toEqual({ x: 100, y: 200 }); + }); + + it('anchors preview edges on the slot canonical handle ids so connection filtering can resolve them', () => { + const slot: InsertionSlot = { + id: 'slot-1', + source: { nodeId: 'container', handleId: 'true' }, + target: { nodeId: 'b', handleId: 'input' }, + }; + const options = buildSequentialPreviewOptions(makeInstance(), { ...baseArgs, slot }); + // Node id and handle id both come from the slot: bars now render an + // invisible handle per manifest handle id (BaseNodeBar + // manifestSource/TargetHandleIds), so the preview edge renders AND + // usePreviewNode can resolve the existing node's handle manifest for the + // Add Node connection filter (bar handle ids never appeared in the manifest). + expect(options.source).toEqual({ nodeId: 'container', handleId: 'true' }); + expect(options.target).toEqual({ nodeId: 'b', handleId: 'input' }); + }); + + it('falls back to the default handle ids when the slot omits them (tail append)', () => { + const slot: InsertionSlot = { id: 'slot-tail', source: { nodeId: 'last' } }; + const options = buildSequentialPreviewOptions(makeInstance([]), { + ...baseArgs, + slot, + target: '', + targetHandleId: undefined, + }); + expect(options.source).toEqual({ nodeId: 'last', handleId: DEFAULT_SOURCE_HANDLE_ID }); + expect('target' in options).toBe(false); + }); + + it('drives a head insertion from the first canonical target without using the synthetic start row', () => { + const slot: InsertionSlot = { id: 'slot-head', target: { nodeId: 'first' } }; + const options = buildSequentialPreviewOptions(makeInstance(), { + ...baseArgs, + source: '__sequential-start__', + target: 'first', + slot, + }); + + expect(options.source).toEqual({ nodeId: 'first', handleId: DEFAULT_TARGET_HANDLE_ID }); + expect(options.sourceHandleType).toBe('target'); + expect(options.handlePosition).toBe(Position.Top); + expect('target' in options).toBe(false); + }); + + it('stashes the split CONNECTOR edge (found by its own rendered id) on data.originalEdge', () => { + const slot: InsertionSlot = { id: 'slot-1', graphEdgeId: 'e-1' }; + const options = buildSequentialPreviewOptions(makeInstance(), { + ...baseArgs, + slot, + connectorEdgeId: 'conn:e-1', + }); + expect(options.data).toEqual({ originalEdge: connectorEdge }); + }); + + it('never finds an edge via slot.graphEdgeId alone (different id space)', () => { + // Regression: slot.graphEdgeId ('e-1') is the CANONICAL edge id; the store + // here only holds the derived connector edge ('conn:e-1'). Omitting + // connectorEdgeId must not accidentally match by falling back to graphEdgeId. + const slot: InsertionSlot = { id: 'slot-1', graphEdgeId: 'e-1' }; + const options = buildSequentialPreviewOptions(makeInstance(), { ...baseArgs, slot }); + expect(options.data).toEqual({}); + }); + + it('omits originalEdge when the insert has no backing connector (tail append)', () => { + const slot: InsertionSlot = { id: 'slot-1' }; + const options = buildSequentialPreviewOptions(makeInstance(), { ...baseArgs, slot }); + expect(options.data).toEqual({}); + }); + + it('never forwards slot.containerId into the preview graph options', () => { + // createPreviewGraph would reparent the preview onto the container's + // rendered clone with extent:'parent'; in sequential view that clone is a + // flattened bar, so the preview would clamp onto the container's own row. + // Canonical containment is applied to the materialized node instead (see + // resolvePendingSequentialInsert / sequentialOnBeforeNodeAdded). + const withContainer = buildSequentialPreviewOptions(makeInstance(), { + ...baseArgs, + slot: { id: 'slot-1', containerId: 'loop-1' }, + }); + expect('containerId' in withContainer).toBe(false); + + const withoutContainer = buildSequentialPreviewOptions(makeInstance(), { + ...baseArgs, + slot: { id: 'slot-1' }, + }); + expect('containerId' in withoutContainer).toBe(false); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/sequentialInsert.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/sequentialInsert.ts new file mode 100644 index 000000000..e725a4d77 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/sequentialInsert.ts @@ -0,0 +1,309 @@ +import type { + Edge, + Node, + ReactFlowInstance, + XYPosition, +} from '@uipath/apollo-react/canvas/xyflow/react'; +import { Position } from '@uipath/apollo-react/canvas/xyflow/react'; +import { + DEFAULT_SOURCE_HANDLE_ID, + DEFAULT_TARGET_HANDLE_ID, + SEQ_BAR_HEIGHT, + SEQ_BAR_WIDTH, + SEQ_PLACEHOLDER_NODE_TYPE, + SEQ_START_NODE_TYPE, +} from '../../../constants'; +import type { CreatePreviewGraphOptions } from '../../../utils/createPreviewGraph'; +import { SEQ_CONTINUATION_EDGE_KEY } from '../../../utils/sequential/graph-helpers'; +import type { InsertionSlot } from '../../../utils/sequential/sequential.types'; + +/** + * Node `type` values excluded from the Add Node collision passes + * (shiftForEdgeInsertion / resolveCollisions, utils/NodeUtils.ts) so they never + * reposition the layout-owned vertical stack (D5). + * + * IMPORTANT: this covers ONLY the synthetic rows. Real step clones KEEP their + * manifest node type, so `ignoredNodeTypes` cannot enumerate them — the + * robust guarantee that layout owns clone positions lives in the change + * filtering, which drops every position/dimension change for clones before + * forwarding onNodesChange to the consumer (seam 2). Listing the synthetic types + * here is defense in depth so the collision pass can't nudge the start bar or + * terminal placeholder either. + * + * The names are re-exported from constants.ts, the single source of truth shared + * with the node components (nodes/index.ts). + */ +export const SEQUENTIAL_IGNORED_NODE_TYPES: string[] = [ + SEQ_START_NODE_TYPE, + SEQ_PLACEHOLDER_NODE_TYPE, +]; + +/** Returns a fresh ignored-types array, optionally merged with host-specific extras. */ +export function getSequentialIgnoredNodeTypes(extra: readonly string[] = []): string[] { + return [...SEQUENTIAL_IGNORED_NODE_TYPES, ...extra]; +} + +/** Marker written on nodes inserted while in sequential view (D4). */ +export const SEQ_INSERTED_FLAG = 'seqInserted'; + +/** + * Marker stashed on a forwarded edge's `data` carrying the exact canonical edge + * id that an insert split, so `forwardSequentialEdgeChanges` can drop that edge + * precisely instead of inferring it. Stripped before the edge reaches the host. + */ +export const SEQ_SPLIT_EDGE_ID_KEY = '__sequentialSplitEdgeId'; + +/** Arguments the ⊕ button gives the insert wiring. */ +export interface SequentialInsertArgs { + /** The slot carried by the clicked connector. */ + slot: InsertionSlot; + /** Fallback source endpoint (the connector's own source), used when the slot omits one. */ + source: string; + /** + * The connector's own rendered source handle (always one of + * the sequential bar's view-only handles). No longer consulted for handle-id + * resolution (see {@link buildSequentialPreviewOptions} and + * {@link resolvePendingSequentialInsert}) — propagating it into canonical + * state is exactly the bug those functions guard against. Kept on the arg + * shape for call-site stability. + */ + sourceHandleId?: string | null; + /** Fallback target endpoint (the connector's own target), used when the slot omits one. */ + target: string; + /** The connector's own rendered target handle. See {@link sourceHandleId}. */ + targetHandleId?: string | null; + /** Side of the source node the preview edge leaves from (bar bottom). */ + sourcePosition: Position; + /** Drop point for the preview, in flow coordinates (the connector midpoint). */ + position: XYPosition; + /** + * The id of the connector edge AS RENDERED (the `id` xyflow gives + * {@link SequentialConnectorEdge}), used to find and stash the split + * connector for cancel-restore. Undefined for slots with no backing edge + * (a tail append, or an empty branch/container body). + */ + connectorEdgeId?: string; +} + +/** + * Builds the {@link CreatePreviewGraphOptions} for a sequential insertion, + * riding the existing Add Node preview pipeline verbatim (D5). Splits into a + * pure builder so the option shape is unit-testable without a live canvas. + * + * The preview node is sized to a bar (SEQ_BAR_WIDTH x SEQ_BAR_HEIGHT) so the + * stack visibly opens a slot. These options retain canonical manifest handles + * as the preview is created. When the sequential projection rebuilds those + * edges, `buildSequentialEdges` renders them on the guaranteed bar handles and + * carries the canonical existing-endpoint handle separately for Add Node + * filtering/materialization. This keeps preview geometry reliable without + * losing connection semantics. The exact slot endpoints are also captured by + * {@link resolvePendingSequentialInsert} and restored by + * {@link sequentialOnBeforeNodeAdded}. + * + * The split connector (found by its OWN rendered id, `connectorEdgeId` — the + * store in sequential view holds derived connector edges, never canonical + * ids) is stashed on `data.originalEdge` so AddNodeManager can hide it while + * the panel is open and restore it if the panel is cancelled. + */ +export function buildSequentialPreviewOptions( + reactFlowInstance: ReactFlowInstance, + args: SequentialInsertArgs +): CreatePreviewGraphOptions { + const { slot, source, target, sourcePosition, position, connectorEdgeId } = args; + + const originalEdge = connectorEdgeId + ? reactFlowInstance.getEdges().find((edge) => edge.id === connectorEdgeId) + : undefined; + + // Any target-only slot has no canonical source node. Drive the same preview + // pipeline from its canonical target handle; sourceHandleType='target' makes + // the preview the upstream source. The Workflow-start connector is one user + // of this general slot shape, without making the synthetic start bar part of + // canonical state or registry constraint checks. + const targetEndpoint = slot.target; + const isTargetOnlyInsert = slot.source === undefined && targetEndpoint !== undefined; + const previewSourceNodeId = isTargetOnlyInsert + ? targetEndpoint.nodeId + : (slot.source?.nodeId ?? source); + const previewSourceHandleId = isTargetOnlyInsert + ? (targetEndpoint.handleId ?? DEFAULT_TARGET_HANDLE_ID) + : (slot.source?.handleId ?? DEFAULT_SOURCE_HANDLE_ID); + + // The target is optional: a tail append (the terminal placeholder) has a source + // but no downstream node. When neither the slot nor the fallback supplies a + // target node id, omit `target` entirely so the pipeline builds a source-only + // preview instead of wiring an edge to an empty id. + const targetNodeId = isTargetOnlyInsert ? undefined : (slot.target?.nodeId ?? target); + + return { + reactFlowInstance, + source: { + nodeId: previewSourceNodeId, + // Preserve the canonical handle in the semantic preview graph. The + // derived sequential edge remaps its visual anchor to the bar handle and + // carries this id as previewConnectionHandleId for registry filtering. + handleId: previewSourceHandleId, + }, + ...(targetNodeId + ? { + target: { + nodeId: targetNodeId, + handleId: slot.target?.handleId ?? DEFAULT_TARGET_HANDLE_ID, + }, + } + : {}), + position, + positionMode: 'drop', + data: originalEdge ? { originalEdge } : {}, + sourceHandleType: isTargetOnlyInsert ? 'target' : 'source', + handlePosition: isTargetOnlyInsert ? Position.Top : sourcePosition, + ignoredNodeTypes: SEQUENTIAL_IGNORED_NODE_TYPES, + previewNodeSize: { width: SEQ_BAR_WIDTH, height: SEQ_BAR_HEIGHT }, + // containerId is intentionally NOT forwarded here: createPreviewGraph would + // reparent the preview onto the container's RENDERED clone with + // extent:'parent', but that clone is a flattened 896x72 bar (sequential + // view has no flow-view container body), so the preview would clamp to + // exactly overlap the container's row instead of showing in the lane. The + // canonical containment the slot describes is applied to the MATERIALIZED + // node instead — see resolvePendingSequentialInsert / sequentialOnBeforeNodeAdded. + }; +} + +/** Canonical containment/handle data resolved from a slot at insert time (D5). */ +export interface PendingSequentialInsert { + sourceNodeId?: string; + targetNodeId?: string; + graphEdgeId?: string; + /** slot.source's canonical handle id, if the split edge had an explicit one. */ + sourceHandleId?: string; + /** slot.target's canonical handle id, if the split edge had an explicit one. */ + targetHandleId?: string; + /** parentId for the materialized node, if the slot is scoped to a container. */ + containerId?: string; + /** Whether the split source edge already represented an explicit continuation. */ + splitEdgeWasContinuation?: boolean; +} + +/** + * Captures the slot's canonical handle/containment data at the moment + * `startInsert` opens the panel, for {@link sequentialOnBeforeNodeAdded} to + * apply once the user picks a node (D5). Handle ids fall back to `undefined` + * (default-handle semantics), never to the connector's own bar handle — the + * bar handle is a view-rendering detail (see buildSequentialPreviewOptions) + * that must never reach canonical state. + */ +export function resolvePendingSequentialInsert(slot: InsertionSlot): PendingSequentialInsert { + return { + sourceNodeId: slot.source?.nodeId, + targetNodeId: slot.target?.nodeId, + graphEdgeId: slot.graphEdgeId, + sourceHandleId: slot.source?.handleId, + targetHandleId: slot.target?.handleId, + containerId: slot.containerId, + splitEdgeWasContinuation: slot.continuation, + }; +} + +/** + * Adapter for `AddNodeManager.onBeforeNodeAdded` (D4/D5). It: + * 1. keeps the preview's position as a harmless placement hint — layout owns + * positions in sequential view and the projection recomputes them on the + * next structural change (D12), so any value here is transient; reusing + * the preview's position (instead of zeroing it) just avoids piling the + * node onto the head of the stack before the collision pass runs; + * 2. stamps `data.seqInserted = true` so `synthesizePositionsForFlow` can + * place the node when the user toggles back to flow view (D4). Does NOT + * stamp `draggable: false` — the sequential clone derivation already forces + * that unconditionally for every row (useSequentialGraph), so doing it here + * too would only leak into canonical state and permanently disable dragging + * once the user toggles back to flow view; + * 3. re-ids the node with `crypto.randomUUID()` and rewrites the new edges to + * reference the new id (the pipeline seeds a `type-Date.now()` id, which can + * collide within the same millisecond); + * 4. applies the slot's canonical containment (`parentId`/`extent`) and + * restores the two existing-node endpoints to the slot's exact canonical + * handles. The inserted node's endpoints are deliberately left alone: + * AddNodeManager resolves those from the inserted node's own manifest. + * `pending` is captured by `useSequentialInsert` from the slot at the + * moment the panel opened (see resolvePendingSequentialInsert); + * 5. marks the inserted-node-to-existing-target edge as the sequence + * continuation. This keeps the old downstream sequence at the insertion + * scope instead of treating it as a branch/body child of the new node. + */ +export function sequentialOnBeforeNodeAdded( + newNode: Node, + newEdges: Edge[], + pending?: PendingSequentialInsert +): { newNode: Node; newEdges: Edge[] } { + const seqId = crypto.randomUUID(); + const previousId = newNode.id; + + const finalNode: Node = { + ...newNode, + id: seqId, + ...(pending?.containerId ? { parentId: pending.containerId, extent: 'parent' as const } : {}), + data: { ...newNode.data, [SEQ_INSERTED_FLAG]: true }, + }; + + const finalEdges = newEdges.map((edge) => { + const nextSource = edge.source === previousId ? seqId : edge.source; + const nextTarget = edge.target === previousId ? seqId : edge.target; + let nextSourceHandle = edge.sourceHandle; + let nextTargetHandle = edge.targetHandle; + // Preview edges use concrete handles so registry filtering works, while + // the split canonical edge may have relied on implicit defaults. Restore + // only the EXISTING nodes' exact endpoint semantics before forwarding; + // the inserted node's handles already came from its own manifest. + if ( + pending?.sourceNodeId && + edge.source === pending.sourceNodeId && + edge.target === previousId + ) { + nextSourceHandle = pending.sourceHandleId; + } + if ( + pending?.targetNodeId && + edge.source === previousId && + edge.target === pending.targetNodeId + ) { + nextTargetHandle = pending.targetHandleId; + } + const preservesSplitContinuation = + pending?.splitEdgeWasContinuation === true && + edge.source === pending.sourceNodeId && + edge.target === previousId; + const keepsDownstreamOutsideInsertedNode = + pending?.targetNodeId !== undefined && + edge.source === previousId && + edge.target === pending.targetNodeId; + const carriesContinuation = preservesSplitContinuation || keepsDownstreamOutsideInsertedNode; + const data = + pending?.graphEdgeId || carriesContinuation + ? { + ...edge.data, + ...(pending?.graphEdgeId ? { [SEQ_SPLIT_EDGE_ID_KEY]: pending.graphEdgeId } : {}), + ...(carriesContinuation ? { [SEQ_CONTINUATION_EDGE_KEY]: true } : {}), + } + : edge.data; + if ( + nextSource === edge.source && + nextTarget === edge.target && + nextSourceHandle === edge.sourceHandle && + nextTargetHandle === edge.targetHandle && + data === edge.data + ) { + return edge; + } + return { + ...edge, + source: nextSource, + target: nextTarget, + sourceHandle: nextSourceHandle, + targetHandle: nextTargetHandle, + data, + id: `edge_${nextSource}-${nextSourceHandle}-${nextTarget}-${nextTargetHandle}`, + }; + }); + + return { newNode: finalNode, newEdges: finalEdges }; +} diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/useSequentialInsert.test.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/useSequentialInsert.test.tsx new file mode 100644 index 000000000..2fb095ac3 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/useSequentialInsert.test.tsx @@ -0,0 +1,106 @@ +import { renderHook } from '@testing-library/react'; +import type { Node } from '@uipath/apollo-react/canvas/xyflow/react'; +import { Position } from '@uipath/apollo-react/canvas/xyflow/react'; +import type { ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import type { InsertionSlot } from '../../../utils/sequential/sequential.types'; +import { SequentialInsertGapProvider } from '../SequentialInsertGapContext'; +import { SequentialInsertStateProvider } from '../SequentialInsertStateContext'; +import { useSequentialInsert } from './useSequentialInsert'; + +// The preview pipeline needs a live RF store; this hook test only exercises the +// pending-slot bookkeeping, so stub the side effect out. +vi.mock('../../../utils/createPreviewGraph', async (importOriginal) => ({ + ...(await importOriginal()), + showPreviewGraph: vi.fn(), +})); + +describe('useSequentialInsert', () => { + it('publishes the clicked slot so the placeholder is swapped for the preview row', () => { + const setGapSlot = vi.fn(); + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + const hook = renderHook(() => useSequentialInsert(), { wrapper }); + const slot: InsertionSlot = { id: 'tail', source: { nodeId: 'last-step' } }; + + hook.result.current.startInsert({ + slot, + source: 'last-step', + target: '', + sourcePosition: Position.Bottom, + position: { x: 0, y: 72 }, + }); + + expect(setGapSlot).toHaveBeenCalledWith(slot); + }); + + it('shares the pending slot across separate hook instances so container parenting survives commit', () => { + // Regression: the connector plus (startInsert) and AddNodeManager's + // onBeforeNodeAdded are DIFFERENT useSequentialInsert instances. A + // per-instance ref left the commit side blind to the slot's containerId, so + // inserts into a For Each body / branch lane dropped parentId and orphaned + // the node. Both hook instances share one per-canvas provider. + const hooks = renderHook( + () => ({ opener: useSequentialInsert(), committer: useSequentialInsert() }), + { wrapper: SequentialInsertStateProvider } + ); + + const slot: InsertionSlot = { + id: 'slot-then', + source: { nodeId: 'if', handleId: 'true' }, + target: { nodeId: 'javascript-1', handleId: 'input' }, + graphEdgeId: 'e-if-then', + containerId: 'for-each', + }; + + hooks.result.current.opener.startInsert({ + slot, + source: 'if', + target: 'javascript-1', + sourcePosition: Position.Bottom, + position: { x: 0, y: 0 }, + }); + + const previewNode: Node = { + id: 'preview-node-id', + type: 'uipath.blank-node', + position: { x: 0, y: 0 }, + data: {}, + }; + const { newNode } = hooks.result.current.committer.onBeforeNodeAdded(previewNode, []); + + // The other instance's commit sees the opener's slot: container parent is + // stamped so the node splices into the lane rather than orphaning at the end. + expect(newNode.parentId).toBe('for-each'); + expect(newNode.extent).toBe('parent'); + }); + + it('isolates pending inserts between canvas providers', () => { + const canvasA = renderHook(() => useSequentialInsert(), { + wrapper: SequentialInsertStateProvider, + }); + const canvasB = renderHook(() => useSequentialInsert(), { + wrapper: SequentialInsertStateProvider, + }); + canvasA.result.current.startInsert({ + slot: { id: 'a', source: { nodeId: 'a' }, containerId: 'container-a' }, + source: 'a', + target: '', + sourcePosition: Position.Bottom, + position: { x: 0, y: 0 }, + }); + canvasB.result.current.startInsert({ + slot: { id: 'b', source: { nodeId: 'b' }, containerId: 'container-b' }, + source: 'b', + target: '', + sourcePosition: Position.Bottom, + position: { x: 0, y: 0 }, + }); + const seed: Node = { id: 'preview-node-id', position: { x: 0, y: 0 }, data: {} }; + expect(canvasA.result.current.onBeforeNodeAdded(seed, []).newNode.parentId).toBe('container-a'); + expect(canvasB.result.current.onBeforeNodeAdded(seed, []).newNode.parentId).toBe('container-b'); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/useSequentialInsert.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/useSequentialInsert.ts new file mode 100644 index 000000000..e21208c76 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/useSequentialInsert.ts @@ -0,0 +1,68 @@ +import type { Edge, Node } from '@uipath/apollo-react/canvas/xyflow/react'; +import { useReactFlow } from '@uipath/apollo-react/canvas/xyflow/react'; +import { useCallback, useRef } from 'react'; +import { showPreviewGraph } from '../../../utils/createPreviewGraph'; +import { useSetSequentialInsertGapSlot } from '../SequentialInsertGapContext'; +import { useOptionalSequentialInsertState } from '../SequentialInsertStateContext'; +import { + buildSequentialPreviewOptions, + resolvePendingSequentialInsert, + type SequentialInsertArgs, + sequentialOnBeforeNodeAdded, +} from './sequentialInsert'; + +export interface UseSequentialInsertResult { + /** + * Opens the Add Node panel for a connector's insertion slot by riding the + * existing preview pipeline (D5). Call this from the ⊕ button's click handler. + */ + startInsert: (args: SequentialInsertArgs) => void; + /** + * Bound `AddNodeManager.onBeforeNodeAdded` adapter. Closes over the slot + * captured by the most recent `startInsert` call so materialization can + * apply the slot's canonical handle ids / containment (D5) — `onBeforeNodeAdded` + * itself only receives the new node and edges, not the slot that started the + * insert, so this hook is the side channel between the two. + */ + onBeforeNodeAdded: (newNode: Node, newEdges: Edge[]) => { newNode: Node; newEdges: Edge[] }; +} + +/** + * Wires the ⊕ insert affordance to the existing Add Node preview pipeline. It + * only kicks off the preview; the panel, constraint filtering, materialization, + * and cancel-restore all run through the unchanged AddNodeManager (which the + * assembly mounts as a child of the canvas with `onBeforeNodeAdded` from this hook and + * `ignoredNodeTypes` from ./sequentialInsert). + */ +export function useSequentialInsert(): UseSequentialInsertResult { + const reactFlowInstance = useReactFlow(); + const setInsertGapSlot = useSetSequentialInsertGapSlot(); + const localPending = useRef | undefined>( + undefined + ); + const sharedState = useOptionalSequentialInsertState(); + const pending = sharedState?.pending ?? localPending; + + const startInsert = useCallback( + (args: SequentialInsertArgs) => { + pending.current = resolvePendingSequentialInsert(args.slot); + // Surfaces the active slot for the insert-preview-gap post-pass: + // SequentialCanvas.tsx owns the state and clears it again + // on cancel/commit (usePreviewNode transitioning closed). + setInsertGapSlot(args.slot); + showPreviewGraph(buildSequentialPreviewOptions(reactFlowInstance, args)); + }, + [reactFlowInstance, setInsertGapSlot, pending] + ); + + const onBeforeNodeAdded = useCallback( + (newNode: Node, newEdges: Edge[]) => { + const result = sequentialOnBeforeNodeAdded(newNode, newEdges, pending.current); + pending.current = undefined; + return result; + }, + [pending] + ); + + return { startInsert, onBeforeNodeAdded }; +} diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/index.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/index.ts new file mode 100644 index 000000000..b0793f856 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/index.ts @@ -0,0 +1,23 @@ +export type { + CanvasViewTransitionResult, + PrepareCanvasViewTransitionOptions, +} from './prepareCanvasViewTransition'; +export { prepareCanvasViewTransition } from './prepareCanvasViewTransition'; +export { SequentialCanvas } from './SequentialCanvas'; +export type { SequentialCanvasProps, ViewSwitcherProps } from './SequentialCanvas.types'; +export type { + SequentialViewContextValue, + SequentialViewProviderProps, +} from './SequentialViewContext'; +export { SequentialViewProvider, useSequentialView } from './SequentialViewContext'; +export { synthesizePositionsForFlow } from './synthesizePositionsForFlow'; +export { useCanvasViewMode } from './useCanvasViewMode'; +export type { SequentialGraph, UseSequentialGraphArgs } from './useSequentialGraph'; +export { deriveSequentialGraph, useSequentialGraph } from './useSequentialGraph'; +export type { + SequentialKeyboardRow, + UseSequentialKeyboardArgs, + UseSequentialKeyboardResult, +} from './useSequentialKeyboard'; +export { useSequentialKeyboard } from './useSequentialKeyboard'; +export { ViewSwitcher } from './ViewSwitcher'; diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/BarVariant.stories.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/BarVariant.stories.tsx new file mode 100644 index 000000000..3dba72d9e --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/BarVariant.stories.tsx @@ -0,0 +1,123 @@ +/** + * Sequential bar variant, visual-drift contract. + * + * Renders the same representative manifests twice: as free-form cards (left) and + * as sequential bars (right), plus the synthetic start and placeholder rows. The + * bar reuses BaseNode's resolved display, so icon, label, and colors match the + * card for every node. This is an early seed of the fuller card/bar contract. + */ + +import type { Meta, StoryObj } from '@storybook/react'; +import type { Node, NodeTypes } from '@uipath/apollo-react/canvas/xyflow/react'; +import { ReactFlowProvider } from '@uipath/apollo-react/canvas/xyflow/react'; +import { useMemo } from 'react'; +import { useNodeTypeRegistry } from '../../../core'; +import { + createNode, + useNodeTypesFromRegistry, + withCanvasProviders, +} from '../../../storybook-utils'; +import { BaseCanvas } from '../../BaseCanvas'; +import { BaseNode } from '../../BaseNode/BaseNode'; +import { + SEQ_PLACEHOLDER_NODE_TYPE, + SEQ_START_NODE_TYPE, + SEQUENTIAL_SYNTHETIC_NODE_TYPES, + SequentialStepNode, +} from './index'; + +const meta: Meta = { + title: 'Components/Nodes/Sequential Bar', + parameters: { layout: 'fullscreen' }, + decorators: [withCanvasProviders()], +}; + +export default meta; +type Story = StoryObj; + +// Representative manifests: a trigger, a branch, a container, a data step, and an +// agent. All exist in the default workflow manifest installed by the decorator. +const REPRESENTATIVE_TYPES = [ + 'uipath.manual-trigger', + 'uipath.control-flow.decision', + 'uipath.control-flow.foreach', + 'uipath.data.transform', + 'uipath.agent', +] as const; + +const CARD_PITCH = 176; +const BAR_PITCH = 128; + +function cardNodes(): Node[] { + return REPRESENTATIVE_TYPES.map((type, i) => + createNode({ id: `card-${i}`, type, position: { x: 0, y: i * CARD_PITCH } }) + ); +} + +function barNodes(): Node[] { + const start: Node = { + id: 'seq-start', + type: SEQ_START_NODE_TYPE, + position: { x: 0, y: 0 }, + data: { onAddTrigger: () => {} }, + draggable: false, + }; + const steps = REPRESENTATIVE_TYPES.map((type, i) => + createNode({ id: `bar-${i}`, type, position: { x: 0, y: (i + 1) * BAR_PITCH } }) + ); + const placeholder: Node = { + id: 'seq-placeholder', + type: SEQ_PLACEHOLDER_NODE_TYPE, + position: { x: 0, y: (REPRESENTATIVE_TYPES.length + 1) * BAR_PITCH }, + data: { onAdd: () => {} }, + draggable: false, + }; + return [start, ...steps, placeholder]; +} + +function BarVariantStory() { + const registry = useNodeTypeRegistry(); + const cardTypes = useNodeTypesFromRegistry(BaseNode); + const barTypes = useMemo(() => { + const types: NodeTypes = { default: SequentialStepNode, ...SEQUENTIAL_SYNTHETIC_NODE_TYPES }; + for (const manifest of registry.getAllManifests()) { + types[manifest.nodeType] = SequentialStepNode; + } + return types; + }, [registry]); + + const cards = useMemo(cardNodes, []); + const bars = useMemo(barNodes, []); + + return ( +
    +
    + + + +
    +
    + + + +
    +
    + ); +} + +export const CardVsBar: Story = { + name: 'Card vs Bar', + render: () => , +}; diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialInsertPreviewNode.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialInsertPreviewNode.tsx new file mode 100644 index 000000000..e5958a88c --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialInsertPreviewNode.tsx @@ -0,0 +1,75 @@ +import type { NodeProps } from '@uipath/apollo-react/canvas/xyflow/react'; +import { Handle, Position } from '@uipath/apollo-react/canvas/xyflow/react'; +import { cn } from '@uipath/apollo-wind'; +import { memo, useMemo } from 'react'; +import { useSafeLingui } from '../../../../i18n'; +import { getIcon } from '../../../utils/icon-registry'; +import { + getSeqBarVars, + INVISIBLE_HANDLE_STYLE, + SEQ_BAR_SHELL_CLASS, + SEQUENTIAL_BAR_HANDLE_IDS, +} from '../../BaseNode/BaseNodeBar'; +import { BaseInnerShape } from '../../BaseNode/BaseNodeInnerShape'; + +/** + * Sequential-view Add Node preview bar. The shared {@link AddNodePreview} + * centers a `min(w,h)`-sized glyph, which in the 896x56 bar leaves a nearly + * empty slab with a lonely icon. This mirrors the real bar layout instead: a + * dashed, de-emphasized bar with the icon box on the LEFT and a "New step" + * label, so the slot the insert will fill reads like a ghost of a real row. + * + * Registered as the `preview` node type only in the sequential view (see + * SequentialCanvas.tsx); the flow view keeps AddNodePreview. Its handle ids + * (`input`/`output`) match the Add Node preview edges, offset to the connector + * spine (SEQ handle offset) so those edges align like a real bar's connectors. + */ +function SequentialInsertPreviewNodeComponent({ width, height }: NodeProps) { + const { _ } = useSafeLingui(); + const label = _({ id: 'sequential-canvas.new-step', message: 'New step' }); + const icon = useMemo(() => { + const IconComponent = getIcon('square-dashed'); + return ; + }, []); + + return ( +
    + + + {icon} + {label} + +
    + ); +} + +export const SequentialInsertPreviewNode = memo(SequentialInsertPreviewNodeComponent); diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialPlaceholderNode.test.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialPlaceholderNode.test.tsx new file mode 100644 index 000000000..5576d7ccd --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialPlaceholderNode.test.tsx @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '../../../utils/testing'; +import { BaseCanvasModeProvider } from '../../BaseCanvas/BaseCanvasModeProvider'; +import { SequentialPlaceholderNode } from './SequentialPlaceholderNode'; + +// The node renders xyflow s, which need a store provider. This test +// only exercises the affordance rendering, so stub them out. +vi.mock('@uipath/apollo-react/canvas/xyflow/react', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, Handle: () => null }; +}); + +// Minimal NodeProps stand-in for a focused render test. +// biome-ignore lint/suspicious/noExplicitAny: minimal NodeProps stub. +const props = (data: object) => ({ id: 'ph', data, width: 896, height: 44 }) as any; + +function renderNode(data: object, mode: 'design' | 'view' = 'design') { + return render( + + + + ); +} + +describe('SequentialPlaceholderNode', () => { + it('renders the dashed Add step row for the row variant (empty lane/body)', () => { + renderNode({ variant: 'row', onAdd: () => {} }); + expect(screen.getByTestId('sequential-placeholder-bar')).toBeInTheDocument(); + // The row shows a visible label. + expect(screen.getByText('Add step')).toBeInTheDocument(); + expect(screen.queryByTestId('sequential-placeholder-plus')).not.toBeInTheDocument(); + }); + + it('renders a quiet plus (no visible label) for the plus variant (append)', () => { + renderNode({ variant: 'plus', onAdd: () => {} }); + expect(screen.getByTestId('sequential-placeholder-plus')).toBeInTheDocument(); + // The plus is icon-only: labelled for a11y, but no visible text. + expect(screen.getByRole('button', { name: 'Add step' })).toBeInTheDocument(); + expect(screen.queryByText('Add step')).not.toBeInTheDocument(); + expect(screen.queryByTestId('sequential-placeholder-bar')).not.toBeInTheDocument(); + }); + + it('defaults to the row variant when none is given', () => { + renderNode({ onAdd: () => {} }); + expect(screen.getByTestId('sequential-placeholder-bar')).toBeInTheDocument(); + }); + + it('calls onAdd when the plus is clicked in design mode', () => { + const onAdd = vi.fn(); + renderNode({ variant: 'plus', onAdd }); + fireEvent.click(screen.getByRole('button', { name: 'Add step' })); + expect(onAdd).toHaveBeenCalledTimes(1); + }); + + it('does not add outside design mode', () => { + const onAdd = vi.fn(); + renderNode({ variant: 'plus', onAdd }, 'view'); + fireEvent.click(screen.getByRole('button', { name: 'Add step' })); + expect(onAdd).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialPlaceholderNode.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialPlaceholderNode.tsx new file mode 100644 index 000000000..28f45c5f8 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialPlaceholderNode.tsx @@ -0,0 +1,140 @@ +import type { Node, NodeProps } from '@uipath/apollo-react/canvas/xyflow/react'; +import { Handle, Position } from '@uipath/apollo-react/canvas/xyflow/react'; +import { cn } from '@uipath/apollo-wind'; +import { memo } from 'react'; +import { useSafeLingui } from '../../../../i18n'; +import { SEQ_HANDLE_LEFT_OFFSET } from '../../../constants'; +import { CanvasIcon } from '../../../utils/icon-registry'; +import { useBaseCanvasMode } from '../../BaseCanvas/BaseCanvasModeProvider'; +import { + getSeqBarVars, + INVISIBLE_HANDLE_STYLE, + SEQ_BAR_SHELL_CLASS, + SEQUENTIAL_BAR_HANDLE_IDS, +} from '../../BaseNode/BaseNodeBar'; +import { CanvasInlineButton } from '../../ButtonHandle/CanvasInlineButton'; + +/** + * The faint-until-hover treatment shared by every sequential insert affordance + * (the connector plus in SequentialInsertButton and this node's plus variant), + * so "add a step" reads and behaves identically wherever it appears. + */ +const SEQ_INSERT_REST_CLASS = + 'opacity-40 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100'; + +export interface SequentialPlaceholderNodeData extends Record { + /** Invoked when the affordance is clicked to add a step into this scope. */ + onAdd?: () => void; + /** View-only slot identity used to recover this row's position when clicked. */ + insertionSlotId?: string; + /** + * Which affordance to render (see `SequenceRow.placeholderKind`): + * - 'row' (default): a dashed "Add step" bar for a genuinely empty lane/body. + * - 'plus': a quiet plus matching the between-step insert, for the trailing + * add point after a populated lane's last step (or the tail). + */ + variant?: 'row' | 'plus'; +} + +/** + * Synthetic "add a step" row. Two shapes, chosen by `data.variant`: + * - 'row': a dashed, bar-sized drop target with a centered plus and label, + * styled after AddNodePreview. Used where a lane or body is genuinely empty + * and there is no connector to host a plus. + * - 'plus': the same quiet plus used between steps, anchored on the incoming + * connector, so appending at the end of a list looks identical to inserting + * between two steps. + * + * It carries a top target handle (the tail follows the last step) and a mid-left + * handle (an empty lane is entered from the side). Injected view-only and + * filtered out of the change callbacks by the canvas assembly. + */ +function SequentialPlaceholderNodeComponent({ + data, + width, + height, +}: NodeProps>) { + const { _ } = useSafeLingui(); + const onAdd = data?.onAdd; + const isDesignMode = useBaseCanvasMode().mode === 'design'; + const label = _({ id: 'sequential-canvas.placeholder.add', message: 'Add step' }); + const variant = data?.variant ?? 'row'; + + const handles = ( + <> + + {/* Mid-left entry so an empty-branch-lane placeholder is entered from the + side like a real branch child (the tail placeholder still uses Top). */} + + + ); + + if (variant === 'plus') { + // The box spans the row gap (no reserved row). Center the plus on the shared + // handle column so it sits in the gap below the last step, exactly like a + // between-step insert. The box itself is non-interactive so only the plus + // takes clicks, and it hides cleanly in readonly without moving any node. + return ( +
    + {handles} +
    + { + e.stopPropagation(); + if (isDesignMode) onAdd?.(); + }} + /> +
    +
    + ); + } + + return ( + + ); +} + +export const SequentialPlaceholderNode = memo(SequentialPlaceholderNodeComponent); diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialStartNode.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialStartNode.tsx new file mode 100644 index 000000000..98eb3eeea --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialStartNode.tsx @@ -0,0 +1,77 @@ +import type { Node, NodeProps } from '@uipath/apollo-react/canvas/xyflow/react'; +import { Handle, Position } from '@uipath/apollo-react/canvas/xyflow/react'; +import { Button, cn } from '@uipath/apollo-wind'; +import { memo } from 'react'; +import { useSafeLingui } from '../../../../i18n'; +import { CanvasIcon } from '../../../utils/icon-registry'; +import { useBaseCanvasMode } from '../../BaseCanvas/BaseCanvasModeProvider'; +import { + getSeqBarVars, + INVISIBLE_HANDLE_STYLE, + SEQ_BAR_SHELL_CLASS, + SEQUENTIAL_BAR_HANDLE_IDS, +} from '../../BaseNode/BaseNodeBar'; +import { BaseInnerShape } from '../../BaseNode/BaseNodeInnerShape'; + +export interface SequentialStartNodeData extends Record { + /** Invoked by the "Add trigger" button. Injected view-side by the canvas. */ + onAddTrigger?: () => void; +} + +/** + * Synthetic first row of the sequential view. A bar-sized shell with a play icon + * and a right-aligned "Add trigger" call to action. It carries only a bottom + * source handle (nothing precedes the start). This node is injected view-only + * and filtered out of the change callbacks by the canvas assembly. + */ +function SequentialStartNodeComponent({ + data, + width, + height, +}: NodeProps>) { + const { _ } = useSafeLingui(); + const onAddTrigger = data?.onAddTrigger; + const isDesignMode = useBaseCanvasMode().mode === 'design'; + + return ( +
    +
    + + + +
    + + + {_({ id: 'sequential-canvas.start.title', message: 'Workflow start' })} + + + {isDesignMode && onAddTrigger && ( + + )} + + +
    + ); +} + +export const SequentialStartNode = memo(SequentialStartNodeComponent); diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialStepNode.test.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialStepNode.test.tsx new file mode 100644 index 000000000..e3a656b0d --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialStepNode.test.tsx @@ -0,0 +1,27 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { BaseCanvasModeProvider } from '../../BaseCanvas/BaseCanvasModeProvider'; +import { SequentialStepNode } from './SequentialStepNode'; + +// Keep the wrapper test lightweight and free of the registry / ReactFlow +// context the manifest-backed bar renderer needs. +vi.mock('../../BaseNode/BaseNodeBarNode', () => ({ + BaseNodeBarNode: () =>
    , +})); + +// Minimal NodeProps stand-in for a focused wrapper test. +// biome-ignore lint/suspicious/noExplicitAny: minimal NodeProps stub for a focused render test. +const nodeProps = { id: 'leaf-a', data: {} } as any; + +describe('SequentialStepNode', () => { + it('renders only the registered bar node; branch insertion lives in placeholder rows', () => { + render( + + + + ); + + expect(screen.getByTestId('base-node-bar')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Add step' })).not.toBeInTheDocument(); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialStepNode.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialStepNode.tsx new file mode 100644 index 000000000..8889f3842 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialStepNode.tsx @@ -0,0 +1,51 @@ +import type { Node, NodeProps } from '@uipath/apollo-react/canvas/xyflow/react'; +import { memo } from 'react'; +import { areNodePropsEqualIgnoringPosition } from '../../../utils/nodePropsEqual'; +import type { BaseNodeData } from '../../BaseNode/BaseNode.types'; +import { BaseNodeBarNode } from '../../BaseNode/BaseNodeBarNode'; +import { useSequentialCollapsedRows } from '../SequentialCollapsedRowsContext'; +import { useSequentialMoveMenuItems } from './useSequentialMoveMenuItems'; + +/** + * Sequential Canvas step row. A thin wrapper around the manifest-backed bar + * renderer. BaseNodeBarNode and BaseNode are sibling renderers that share + * presentation resolution without routing one view through the other. + * + * Register this for every real manifest node type in the sequential nodeTypes + * map (the node keeps its real `type`, so BaseNodeBarNode resolves the manifest). + * + * Reads the view-local collapsed row-id set from context rather than + * `node.data`, so a collapsed collapsible row's bar renders BaseContainer's + * `isStacked` treatment without mutating the sequential clone's data (D12). + * `React.memo`'s prop comparator only gates re-renders from prop changes; + * context updates always re-render consuming descendants regardless, so this + * stays correctly reactive to collapse toggles under + * `areNodePropsEqualIgnoringPosition`. + * + * Also computes the four explorer-like tree-move kebab items from + * `SequentialMoveActionsContext` via `useSequentialMoveMenuItems`, + * passed through `BaseNode.extraMenuItems` (D3). Same context-updates-always- + * re-render reasoning applies: a projection change (which changes which + * moves are available) re-renders this node even though it's memoized. + * + * Appendable branch tails are followed by the same full-width dashed + * SequentialPlaceholderNode used for empty branches, so this wrapper contains + * no separate leaf-specific add affordance. + */ +function SequentialStepNodeComponent(props: NodeProps>) { + const collapsedRowIds = useSequentialCollapsedRows(); + const extraMenuItems = useSequentialMoveMenuItems(props.id); + + return ( + + ); +} + +export const SequentialStepNode = memo( + SequentialStepNodeComponent, + areNodePropsEqualIgnoringPosition +); diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/index.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/index.ts new file mode 100644 index 000000000..9a9a33741 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/index.ts @@ -0,0 +1,40 @@ +import type { NodeTypes } from '@uipath/apollo-react/canvas/xyflow/react'; +import { SEQ_PLACEHOLDER_NODE_TYPE, SEQ_START_NODE_TYPE } from '../../../constants'; +import { SequentialPlaceholderNode } from './SequentialPlaceholderNode'; +import { SequentialStartNode } from './SequentialStartNode'; +import { SequentialStepNode } from './SequentialStepNode'; + +// Handle ids every bar exposes (re-exported for the connector/insert pipeline). +export { + INVISIBLE_HANDLE_STYLE, + SEQUENTIAL_BAR_HANDLE_IDS, +} from '../../BaseNode/BaseNodeBar'; +export { SequentialInsertPreviewNode } from './SequentialInsertPreviewNode'; +export { + SequentialPlaceholderNode, + type SequentialPlaceholderNodeData, +} from './SequentialPlaceholderNode'; +export { + SequentialStartNode, + type SequentialStartNodeData, +} from './SequentialStartNode'; +export { SequentialStepNode } from './SequentialStepNode'; + +/** + * Synthetic node type keys for the start and placeholder rows. The canvas + * the assembly stamps injected nodes with these `type` values and merges + * these into the nodeTypes map; every real manifest type maps to + * {@link SequentialStepNode}. Defined in constants.ts so the insert pipeline + * (edges/sequentialInsert.ts) shares the exact same source of truth. + */ +export { SEQ_PLACEHOLDER_NODE_TYPE, SEQ_START_NODE_TYPE }; + +/** + * Convenience nodeTypes entries for the synthetic rows. Spread these into the + * per-view nodeTypes map alongside the registry types mapped to + * {@link SequentialStepNode}. + */ +export const SEQUENTIAL_SYNTHETIC_NODE_TYPES: NodeTypes = { + [SEQ_START_NODE_TYPE]: SequentialStartNode, + [SEQ_PLACEHOLDER_NODE_TYPE]: SequentialPlaceholderNode, +}; diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/useSequentialMoveMenuItems.test.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/useSequentialMoveMenuItems.test.tsx new file mode 100644 index 000000000..fd8d94e9c --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/useSequentialMoveMenuItems.test.tsx @@ -0,0 +1,119 @@ +import { renderHook } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { BaseCanvasModeProvider } from '../../BaseCanvas/BaseCanvasModeProvider'; +import type { NodeMenuAction } from '../../NodeContextMenu'; +import { + type SequentialMoveActionsContextValue, + SequentialMoveActionsProvider, +} from '../SequentialMoveActionsContext'; +import type { SequentialMoveOptions } from '../sequentialMoveActions'; +import { useSequentialMoveMenuItems } from './useSequentialMoveMenuItems'; + +function makeMoveActions( + overrides: Partial = {} +): SequentialMoveActionsContextValue { + return { + getMoveOptions: () => ({ + up: undefined, + down: undefined, + indent: undefined, + outdent: undefined, + }), + commitMove: vi.fn(), + centerOnNode: vi.fn(), + ...overrides, + }; +} + +function wrapper( + mode: 'design' | 'view' | 'readonly', + moveActions?: SequentialMoveActionsContextValue +) { + return ({ children }: { children: ReactNode }) => ( + + {moveActions ? ( + + {children} + + ) : ( + children + )} + + ); +} + +describe('useSequentialMoveMenuItems', () => { + it('returns an empty array outside a SequentialMoveActionsContext provider', () => { + const { result } = renderHook(() => useSequentialMoveMenuItems('a'), { + wrapper: wrapper('design'), + }); + expect(result.current).toEqual([]); + }); + + it('returns an empty array outside design mode, even with a provider', () => { + const moveActions = makeMoveActions(); + const { result } = renderHook(() => useSequentialMoveMenuItems('a'), { + wrapper: wrapper('view', moveActions), + }); + expect(result.current).toEqual([]); + }); + + it('builds four items with localized labels, in design mode with a provider', () => { + const moveActions = makeMoveActions(); + const { result } = renderHook(() => useSequentialMoveMenuItems('a'), { + wrapper: wrapper('design', moveActions), + }); + + expect(result.current).toHaveLength(4); + const labels = (result.current as NodeMenuAction[]).map((item) => item.label); + expect(labels).toEqual(['Move up', 'Move down', 'Move into previous step', 'Move out']); + }); + + it('disables an item when its direction has no slot, and enables it otherwise', () => { + const options: SequentialMoveOptions = { + up: { id: 'up-slot', source: { nodeId: 'prev' } }, + down: undefined, + indent: undefined, + outdent: undefined, + }; + const moveActions = makeMoveActions({ getMoveOptions: () => options }); + const { result } = renderHook(() => useSequentialMoveMenuItems('a'), { + wrapper: wrapper('design', moveActions), + }); + + const items = result.current as NodeMenuAction[]; + expect(items.find((item) => item.label === 'Move up')?.disabled).toBe(false); + expect(items.find((item) => item.label === 'Move down')?.disabled).toBe(true); + }); + + it('clicking an enabled item calls commitMove with the node id and the resolved slot', () => { + const slot = { id: 'up-slot', source: { nodeId: 'prev' } }; + const commitMove = vi.fn(); + const moveActions = makeMoveActions({ + getMoveOptions: () => ({ up: slot, down: undefined, indent: undefined, outdent: undefined }), + commitMove, + }); + const { result } = renderHook(() => useSequentialMoveMenuItems('node-a'), { + wrapper: wrapper('design', moveActions), + }); + + const upItem = (result.current as NodeMenuAction[]).find((item) => item.label === 'Move up')!; + upItem.onClick(); + expect(commitMove).toHaveBeenCalledWith('node-a', slot); + }); + + it('clicking a disabled item does not call commitMove', () => { + const commitMove = vi.fn(); + const moveActions = makeMoveActions({ commitMove }); + const { result } = renderHook(() => useSequentialMoveMenuItems('node-a'), { + wrapper: wrapper('design', moveActions), + }); + + const downItem = (result.current as NodeMenuAction[]).find( + (item) => item.label === 'Move down' + )!; + downItem.onClick(); + expect(commitMove).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/useSequentialMoveMenuItems.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/useSequentialMoveMenuItems.tsx new file mode 100644 index 000000000..945bd4550 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/useSequentialMoveMenuItems.tsx @@ -0,0 +1,75 @@ +import { useMemo } from 'react'; +import { useSafeLingui } from '../../../../i18n'; +import { CanvasIcon } from '../../../utils/icon-registry'; +import { useBaseCanvasMode } from '../../BaseCanvas/BaseCanvasModeProvider'; +import type { NodeMenuAction, NodeMenuItem } from '../../NodeContextMenu'; +import { useOptionalSequentialMoveActions } from '../SequentialMoveActionsContext'; + +/** + * Builds the four kebab "explorer-like tree move" items (Move up, Move down, + * Move into previous step, Move out) for a Sequential Canvas step row, backed + * by the engine's binding contract + * (utils/sequential/mutations.ts, slotNavigation.ts). Consumed by + * `SequentialStepNode` and passed to `BaseNode`'s `extraMenuItems` (D3: no new + * `BaseNodeOverrideConfig` field -- a direct component prop instead). + * + * Disabled state comes straight from `SequentialMoveActionsContext.getMoveOptions` + * (see `sequentialMoveActions.ts` for the disable logic, including the + * bare-branch-owner gate extended to all four directions). Move actions are a + * TOPOLOGY mutation (D4), so -- like the ⊕ insert affordance + * (`SequentialConnectorEdge`'s `showInsert`) -- they only appear in design + * mode; outside a `SequentialMoveActionsContext` provider (isolated node + * stories/tests) this returns an empty array rather than throwing. + */ +export function useSequentialMoveMenuItems(nodeId: string): NodeMenuItem[] { + const { _ } = useSafeLingui(); + const isDesignMode = useBaseCanvasMode().mode === 'design'; + const moveActions = useOptionalSequentialMoveActions(); + + return useMemo(() => { + if (!isDesignMode || !moveActions) return []; + const options = moveActions.getMoveOptions(nodeId); + + const item = ( + id: string, + label: string, + icon: string, + slot: (typeof options)['up'] + ): NodeMenuAction => ({ + id, + label, + icon: , + disabled: !slot, + onClick: () => { + if (slot) moveActions.commitMove(nodeId, slot); + }, + }); + + return [ + item( + 'move-up', + _({ id: 'sequential-canvas.move.up', message: 'Move up' }), + 'arrow-up', + options.up + ), + item( + 'move-down', + _({ id: 'sequential-canvas.move.down', message: 'Move down' }), + 'arrow-down', + options.down + ), + item( + 'move-indent', + _({ id: 'sequential-canvas.move.indent', message: 'Move into previous step' }), + 'indent', + options.indent + ), + item( + 'move-outdent', + _({ id: 'sequential-canvas.move.outdent', message: 'Move out' }), + 'outdent', + options.outdent + ), + ]; + }, [isDesignMode, moveActions, nodeId, _]); +} diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/prepareCanvasViewTransition.test.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/prepareCanvasViewTransition.test.ts new file mode 100644 index 000000000..1197f8cfc --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/prepareCanvasViewTransition.test.ts @@ -0,0 +1,95 @@ +import type { Edge, Node } from '@uipath/apollo-react/canvas/xyflow/react'; +import { describe, expect, it } from 'vitest'; +import { makeWireframeFixture, WIREFRAME_NODE_IDS } from '../../utils/sequential/fixtures'; +import { SEQ_INSERTED_FLAG } from './edges/sequentialInsert'; +import { prepareCanvasViewTransition } from './prepareCanvasViewTransition'; + +describe('prepareCanvasViewTransition', () => { + it('keeps the canonical trigger for Flow while Sequential projects it into the start row', () => { + const fixture = makeWireframeFixture(); + + const flow = prepareCanvasViewTransition('flow', fixture.nodes, fixture.edges); + const sequential = prepareCanvasViewTransition('sequential', flow.nodes, fixture.edges); + + expect(flow.nodes.find((item) => item.id === WIREFRAME_NODE_IDS.trigger)?.type).toBe( + 'uipath.first-run' + ); + expect( + sequential.sequentialCompatibility?.projectedNodeIds.includes(WIREFRAME_NODE_IDS.trigger) + ).toBe(false); + expect(sequential.sequentialCompatibility?.projectedNodeIds[0]).toBe(WIREFRAME_NODE_IDS.http); + }); + + it('applies a full left-to-right layout when entering flow', () => { + const nodes = [node('a', 800), node('b', 0), node('c', 400)]; + const edges = [edge('a', 'b'), edge('b', 'c')]; + + const result = prepareCanvasViewTransition('flow', nodes, edges, { + flowLayout: { + rankGap: 40, + getNodeDimensions: () => ({ width: 100, height: 60 }), + }, + }); + + expect(result.nodes.map((item) => item.position)).toEqual([ + { x: 0, y: 0 }, + { x: 140, y: 0 }, + { x: 280, y: 0 }, + ]); + expect(result.flowLayout).toBeDefined(); + }); + + it('clears sequential insert markers as part of the flow transition', () => { + const inserted: Node = { + ...node('inserted', 0), + draggable: false, + data: { [SEQ_INSERTED_FLAG]: true }, + }; + + const result = prepareCanvasViewTransition( + 'flow', + [node('a', 0), inserted], + [edge('a', 'inserted')] + ); + + expect(result.nodes[1]!.data).not.toHaveProperty(SEQ_INSERTED_FLAG); + expect(result.nodes[1]!.draggable).toBeUndefined(); + }); + + it('analyzes sequential compatibility without changing canonical nodes', () => { + const nodes = [node('a', 0), node('b', 100), node('c', 200)]; + const edges = [edge('a', 'b'), edge('b', 'c'), edge('c', 'a')]; + + const result = prepareCanvasViewTransition('sequential', nodes, edges); + + expect(result.nodes).toBe(nodes); + expect(result.sequentialCompatibility?.level).toBe('degraded'); + expect(result.sequentialCompatibility?.editable).toBe(false); + expect(result.flowLayout).toBeUndefined(); + }); + + it('preserves sticky-note geometry in both transitions by default', () => { + const sticky: Node = { + ...node('note', 900), + type: 'stickyNote', + position: { x: 900, y: 700 }, + }; + const nodes = [node('a', 100), node('b', 200), sticky]; + const edges = [edge('a', 'b'), edge('note', 'a')]; + + const flow = prepareCanvasViewTransition('flow', nodes, edges); + const sequential = prepareCanvasViewTransition('sequential', flow.nodes, edges); + + expect(flow.nodes.find((item) => item.id === sticky.id)?.position).toEqual({ x: 900, y: 700 }); + expect(sequential.sequentialCompatibility?.preservedOnlyNodeIds).toEqual(['note']); + expect(sequential.sequentialCompatibility?.preservedOnlyEdgeIds).toContain('note-a'); + }); +}); + +function node(id: string, x: number): Node { + return { id, type: 'task', position: { x, y: 0 }, data: {} }; +} + +function edge(source: string, target: string): Edge { + return { id: `${source}-${target}`, source, target }; +} diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/prepareCanvasViewTransition.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/prepareCanvasViewTransition.ts new file mode 100644 index 000000000..d13011239 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/prepareCanvasViewTransition.ts @@ -0,0 +1,98 @@ +import type { Edge, Node } from '@uipath/apollo-react/canvas/xyflow/react'; +import { + type AnalyzeSequentialCompatibilityOptions, + analyzeSequentialCompatibility, + type CanvasView, + type SequentialCompatibilityReport, +} from '../../utils/sequential'; +import { + layoutWorkflowLeftToRight, + type WorkflowLayoutOptions, + type WorkflowLayoutResult, +} from '../../utils/workflow-layout'; +import { synthesizePositionsForFlow } from './synthesizePositionsForFlow'; + +export interface PrepareCanvasViewTransitionOptions { + flowLayout?: WorkflowLayoutOptions; + sequential?: AnalyzeSequentialCompatibilityOptions; +} + +export interface CanvasViewTransitionResult { + view: CanvasView; + /** Canonical nodes after applying presentation state required by the target view. */ + nodes: N[]; + /** Present when entering flow; useful for viewport fitting and diagnostics. */ + flowLayout?: WorkflowLayoutResult; + /** Present when entering sequential; topology is analyzed but never rewritten. */ + sequentialCompatibility?: SequentialCompatibilityReport; +} + +/** + * Prepares one canonical workflow graph for a presentation switch. + * + * Flow is a concrete left-to-right layout, so its positions and container sizes + * become canonical presentation state. Sequential is a derived projection: it + * only receives a compatibility report and leaves every node object untouched. + */ +export function prepareCanvasViewTransition( + targetView: CanvasView, + nodes: N[], + edges: Edge[], + options: PrepareCanvasViewTransitionOptions = {} +): CanvasViewTransitionResult { + const isWorkflowNode = (node: Node) => node.type !== 'stickyNote'; + if (targetView === 'sequential') { + return { + view: targetView, + nodes, + sequentialCompatibility: analyzeSequentialCompatibility(nodes, edges, { + ...options.sequential, + isSequenceNode: options.sequential?.isSequenceNode ?? isWorkflowNode, + }), + }; + } + + // Clear sequential-insert markers before laying out the entire flow. The + // synthesized coordinates are deliberately superseded by the deterministic + // layout, but marker cleanup still restores normal draggable behavior. + const normalizedNodes = synthesizePositionsForFlow(nodes, edges) as N[]; + const flowLayout = layoutWorkflowLeftToRight(normalizedNodes, edges, { + ...options.flowLayout, + isLayoutNode: options.flowLayout?.isLayoutNode ?? isWorkflowNode, + }); + let changed = normalizedNodes !== nodes; + const nextNodes = normalizedNodes.map((node) => { + const position = flowLayout.positions.get(node.id); + if (!position) return node; + const size = flowLayout.resizedNodeIds.has(node.id) + ? flowLayout.dimensions.get(node.id) + : undefined; + const positionChanged = node.position.x !== position.x || node.position.y !== position.y; + const sizeChanged = + !!size && + (node.width !== size.width || + node.height !== size.height || + node.style?.width !== size.width || + node.style?.height !== size.height); + if (!positionChanged && !sizeChanged) return node; + + changed = true; + return { + ...node, + position, + ...(size + ? { + width: size.width, + height: size.height, + style: { ...node.style, width: size.width, height: size.height }, + } + : {}), + }; + }); + + return { + view: targetView, + nodes: changed ? nextNodes : nodes, + flowLayout, + }; +} diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/resolveFlowEdgeTypes.test.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/resolveFlowEdgeTypes.test.ts new file mode 100644 index 000000000..61a913ce3 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/resolveFlowEdgeTypes.test.ts @@ -0,0 +1,29 @@ +import type { EdgeProps, EdgeTypes } from '@uipath/apollo-react/canvas/xyflow/react'; +import { describe, expect, it } from 'vitest'; +import { SequenceEdge } from '../Edges'; +import { resolveFlowEdgeTypes } from './resolveFlowEdgeTypes'; + +const CustomEdge = (_props: EdgeProps) => null; + +describe('resolveFlowEdgeTypes', () => { + it('uses the same default SequenceEdge preset as the Flow canvas', () => { + expect(resolveFlowEdgeTypes().default).toBe(SequenceEdge); + expect(resolveFlowEdgeTypes().sequence).toBe(SequenceEdge); + }); + + it('retains the Flow default when adding a named host edge type', () => { + const result = resolveFlowEdgeTypes({ custom: CustomEdge } as EdgeTypes); + + expect(result.default).toBe(SequenceEdge); + expect(result.sequence).toBe(SequenceEdge); + expect(result.custom).toBe(CustomEdge); + }); + + it('allows the host to replace the default edge renderer', () => { + expect(resolveFlowEdgeTypes({ default: CustomEdge }).default).toBe(CustomEdge); + }); + + it('allows the host to replace the named sequence edge renderer', () => { + expect(resolveFlowEdgeTypes({ sequence: CustomEdge }).sequence).toBe(CustomEdge); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/resolveFlowEdgeTypes.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/resolveFlowEdgeTypes.ts new file mode 100644 index 000000000..165bbb8ba --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/resolveFlowEdgeTypes.ts @@ -0,0 +1,12 @@ +import type { EdgeTypes } from '@uipath/apollo-react/canvas/xyflow/react'; +import { SequenceEdge } from '../Edges'; + +const DEFAULT_FLOW_EDGE_TYPES: EdgeTypes = { + default: SequenceEdge, + sequence: SequenceEdge, +}; + +/** Matches the standard Flow canvas edge preset while allowing host overrides. */ +export function resolveFlowEdgeTypes(overrides?: EdgeTypes): EdgeTypes { + return overrides ? { ...DEFAULT_FLOW_EDGE_TYPES, ...overrides } : DEFAULT_FLOW_EDGE_TYPES; +} diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/resolveFlowNodeComponent.test.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/resolveFlowNodeComponent.test.ts new file mode 100644 index 000000000..f8baabbe7 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/resolveFlowNodeComponent.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { allNodeManifests } from '../../storybook-utils/manifests'; +import { BaseNode } from '../BaseNode/BaseNode'; +import { LoopCanvasNode, resolveContainerPreviewConnectionHandles } from '../LoopNode'; +import { resolveFlowNodeComponent } from './resolveFlowNodeComponent'; + +describe('Flow loop-container experience', () => { + const manifest = (nodeType: string) => + allNodeManifests.find((candidate) => candidate.nodeType === nodeType)!; + + it.each([ + 'uipath.control-flow.foreach', + 'uipath.control-flow.while', + ])('renders %s with LoopCanvasNode', (nodeType) => { + expect(manifest(nodeType).display.shape).toBe('container'); + expect(resolveFlowNodeComponent(manifest(nodeType))).toBe(LoopCanvasNode); + }); + + it('keeps While body and loop-back handles on the inner container boundary', () => { + const whileManifest = manifest('uipath.control-flow.while'); + const innerHandles = whileManifest.handleConfiguration + .filter((group) => group.boundary === 'inner') + .flatMap((group) => group.handles.map((handle) => handle.id)); + + expect(innerHandles).toEqual(['body', 'loopBack']); + }); + + it.each([ + ['uipath.control-flow.foreach', 'start', 'continue'], + ['uipath.control-flow.while', 'body', 'loopBack'], + ])('supports first-child preview wiring for %s', (nodeType, sourceHandleId, targetHandleId) => { + expect( + resolveContainerPreviewConnectionHandles(manifest(nodeType), { nodeId: 'loop' }) + ).toEqual(expect.objectContaining({ sourceHandleId, targetHandleId })); + }); + + it('continues to render regular workflow steps with BaseNode', () => { + expect(resolveFlowNodeComponent(manifest('uipath.script'))).toBe(BaseNode); + }); + + it('gives the canonical first-run circle an output anchor for its Flow edge', () => { + const firstRun = manifest('uipath.first-run'); + const output = firstRun.handleConfiguration + .flatMap((group) => group.handles) + .find((handle) => handle.id === 'output'); + + expect(firstRun.display.shape).toBe('circle'); + expect(output).toMatchObject({ type: 'source', handleType: 'output' }); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/resolveFlowNodeComponent.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/resolveFlowNodeComponent.ts new file mode 100644 index 000000000..84904e739 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/resolveFlowNodeComponent.ts @@ -0,0 +1,12 @@ +import type { NodeTypes } from '@uipath/apollo-react/canvas/xyflow/react'; +import type { NodeManifest } from '../../schema'; +import { isContainerNodeManifest } from '../../utils'; +import { BaseNode } from '../BaseNode/BaseNode'; +import { LoopCanvasNode } from '../LoopNode'; + +/** Resolves the standard Flow renderer from the manifest's semantic shape. */ +export function resolveFlowNodeComponent( + manifest: Pick | undefined +): NodeTypes[string] { + return isContainerNodeManifest(manifest) ? LoopCanvasNode : BaseNode; +} diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialChangeFilters.test.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialChangeFilters.test.ts new file mode 100644 index 000000000..181f9940e --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialChangeFilters.test.ts @@ -0,0 +1,262 @@ +import type { Edge, EdgeChange, Node, NodeChange } from '@uipath/apollo-react/canvas/xyflow/react'; +import { describe, expect, it } from 'vitest'; +import { PREVIEW_EDGE_ID, PREVIEW_NODE_ID } from '../../constants'; +import type { GraphChangeSet } from '../../utils/sequential/sequential.types'; +import { SEQ_INSERTED_FLAG } from './edges/sequentialInsert'; +import { + forwardSequentialEdgeChanges, + forwardSequentialNodeChanges, + graphChangeSetToEdgeChanges, + graphChangeSetToNodeChanges, +} from './sequentialChangeFilters'; +import { SEQ_CONNECTOR_EDGE_TYPE, SEQ_START_ROW_ID } from './sequentialGraph.constants'; + +const SYNTHETIC = new Set([SEQ_START_ROW_ID]); + +function canonical(id: string, position = { x: 100, y: 100 }): Node { + return { id, type: 'uipath.script', position, width: 288, data: { display: { label: id } } }; +} + +describe('forwardSequentialNodeChanges', () => { + const canonicalById = new Map([['a', canonical('a')]]); + + it('drops position and dimension changes (the derivation owns geometry)', () => { + const changes: NodeChange[] = [ + { type: 'position', id: 'a', position: { x: 9, y: 9 } }, + { type: 'dimensions', id: 'a', dimensions: { width: 896, height: 72 } }, + ]; + expect(forwardSequentialNodeChanges(changes, SYNTHETIC, canonicalById)).toEqual([]); + }); + + it('drops changes referencing synthetic rows and the preview node', () => { + const changes: NodeChange[] = [ + { type: 'select', id: SEQ_START_ROW_ID, selected: true }, + { type: 'select', id: PREVIEW_NODE_ID, selected: true }, + { type: 'remove', id: PREVIEW_NODE_ID }, + ]; + expect(forwardSequentialNodeChanges(changes, SYNTHETIC, canonicalById)).toEqual([]); + }); + + it('drops changes referencing synthetic empty-lane placeholder nodes', () => { + const synthetic = new Set([ + ...SYNTHETIC, + '__sequential-lane__if::true', + '__sequential-lane__loop::start', + ]); + const changes: NodeChange[] = [ + { type: 'select', id: '__sequential-lane__if::true', selected: true }, + { type: 'remove', id: '__sequential-lane__loop::start' }, + ]; + expect(forwardSequentialNodeChanges(changes, synthetic, canonicalById)).toEqual([]); + }); + + it('does not drop a canonical node merely because its id resembles a synthetic id', () => { + const id = '__sequential-lane__consumer-owned'; + const changes: NodeChange[] = [{ type: 'select', id, selected: true }]; + + expect(forwardSequentialNodeChanges(changes, SYNTHETIC, canonicalById)).toEqual(changes); + }); + + it('passes select and remove of real nodes through', () => { + const changes: NodeChange[] = [ + { type: 'select', id: 'a', selected: true }, + { type: 'remove', id: 'a' }, + ]; + expect(forwardSequentialNodeChanges(changes, SYNTHETIC, canonicalById)).toEqual(changes); + }); + + it('rewrites a rename replace to merge data onto canonical, preserving geometry', () => { + const cloneItem: Node = { + id: 'a', + type: 'uipath.script', + position: { x: 0, y: 5000 }, // clone/view position + width: 896, // bar width + selected: true, + data: { display: { label: 'renamed' } }, + }; + const changes: NodeChange[] = [{ type: 'replace', id: 'a', item: cloneItem }]; + + const [out] = forwardSequentialNodeChanges(changes, SYNTHETIC, canonicalById); + expect(out?.type).toBe('replace'); + const item = (out as { item: Node }).item; + // Canonical geometry preserved, only data + selection merged. + expect(item.position).toEqual({ x: 100, y: 100 }); + expect(item.width).toBe(288); + expect(item.selected).toBe(true); + expect((item.data as { display: { label: string } }).display.label).toBe('renamed'); + }); + + it('forwards an inserted node add, drops non-inserted adds', () => { + const insertedItem: Node = { + id: 'new', + type: 'uipath.slack', + position: { x: 0, y: 0 }, + data: { [SEQ_INSERTED_FLAG]: true }, + }; + const changes: NodeChange[] = [ + { type: 'add', item: insertedItem }, + { type: 'add', item: canonical('other') }, + ]; + const out = forwardSequentialNodeChanges(changes, SYNTHETIC, canonicalById); + expect(out).toHaveLength(1); + expect((out[0] as { item: Node }).item.id).toBe('new'); + }); +}); + +describe('forwardSequentialEdgeChanges', () => { + const canonicalEdges: Edge[] = [ + { id: 'e-st', source: 's', target: 't' }, + { id: 'e-other', source: 'x', target: 'y' }, + ]; + const canonicalEdgeIds = new Set(canonicalEdges.map((edge) => edge.id)); + + it('drops connector and preview edge adds', () => { + const changes: EdgeChange[] = [ + { + type: 'add', + item: { id: 'conn:1', source: 's', target: 't', type: SEQ_CONNECTOR_EDGE_TYPE }, + }, + { type: 'add', item: { id: PREVIEW_EDGE_ID, source: 's', target: PREVIEW_NODE_ID } }, + ]; + expect(forwardSequentialEdgeChanges(changes, canonicalEdgeIds, canonicalEdges)).toEqual([]); + }); + + it('forwards healed default edges and removes the shadowed canonical edge on insert', () => { + const changes: EdgeChange[] = [ + { type: 'add', item: { id: 'edge_s--new-', source: 's', target: 'new', type: 'default' } }, + { type: 'add', item: { id: 'edge_new--t-', source: 'new', target: 't', type: 'default' } }, + ]; + const out = forwardSequentialEdgeChanges(changes, canonicalEdgeIds, canonicalEdges); + + // Both healed edges forwarded. + expect(out.filter((change) => change.type === 'add')).toHaveLength(2); + // The canonical s->t edge is now shadowed and removed. + expect(out).toContainEqual({ type: 'remove', id: 'e-st' }); + // The unrelated canonical edge is untouched. + expect(out).not.toContainEqual({ type: 'remove', id: 'e-other' }); + }); + + it('uses the exact split marker and preserves parallel canonical edges', () => { + const parallelEdges: Edge[] = [ + { id: 'e-split', source: 's', sourceHandle: 'out', target: 't', targetHandle: 'in' }, + { id: 'e-parallel', source: 's', sourceHandle: 'out', target: 't', targetHandle: 'in' }, + ]; + const ids = new Set(parallelEdges.map((edge) => edge.id)); + const changes: EdgeChange[] = [ + { + type: 'add', + item: { + id: 'e-s-new', + source: 's', + sourceHandle: 'out', + target: 'new', + data: { __sequentialSplitEdgeId: 'e-split', keep: true }, + }, + }, + { + type: 'add', + item: { + id: 'e-new-t', + source: 'new', + target: 't', + targetHandle: 'in', + data: { __sequentialSplitEdgeId: 'e-split' }, + }, + }, + ]; + + const out = forwardSequentialEdgeChanges(changes, ids, parallelEdges); + expect(out).toContainEqual({ type: 'remove', id: 'e-split' }); + expect(out).not.toContainEqual({ type: 'remove', id: 'e-parallel' }); + const added = out.filter((change) => change.type === 'add'); + expect(added[0]?.item.data).toEqual({ keep: true }); + expect(added[1]?.item.data).toEqual({}); + }); + + it('forwards removes/selects of real canonical edges, drops others', () => { + const changes: EdgeChange[] = [ + { type: 'remove', id: 'e-st' }, + { type: 'remove', id: 'conn:1' }, + { type: 'select', id: 'e-other', selected: true }, + { type: 'select', id: 'conn:2', selected: true }, + ]; + const out = forwardSequentialEdgeChanges(changes, canonicalEdgeIds, canonicalEdges); + expect(out).toEqual([ + { type: 'remove', id: 'e-st' }, + { type: 'select', id: 'e-other', selected: true }, + ]); + }); +}); + +describe('graphChangeSetToNodeChanges (move operations)', () => { + it('produces a plain remove for an id that is only in removeNodeIds', () => { + const changeSet: GraphChangeSet = { + addNodes: [], + addEdges: [], + removeNodeIds: ['a'], + removeEdgeIds: [], + }; + expect(graphChangeSetToNodeChanges(changeSet)).toEqual([{ type: 'remove', id: 'a' }]); + }); + + it('produces a plain add for a genuinely new node (not also in removeNodeIds)', () => { + const node = canonical('new'); + const changeSet: GraphChangeSet = { + addNodes: [node], + addEdges: [], + removeNodeIds: [], + removeEdgeIds: [], + }; + expect(graphChangeSetToNodeChanges(changeSet)).toEqual([{ type: 'add', item: node }]); + }); + + it('rewrites a paired remove+add (same id, e.g. a cross-container parentId rewrite) into a single replace, preserving parentId', () => { + const movedNode: Node = { ...canonical('a'), parentId: 'new-container' }; + const changeSet: GraphChangeSet = { + addNodes: [movedNode], + addEdges: [], + removeNodeIds: ['a'], + removeEdgeIds: [], + }; + const changes = graphChangeSetToNodeChanges(changeSet); + expect(changes).toEqual([{ type: 'replace', id: 'a', item: movedNode }]); + // The parentId rewrite -- the whole point of the move -- survives. + expect((changes[0] as { item: Node }).item.parentId).toBe('new-container'); + }); + + it('produces no node changes for a same-container move (only edges change)', () => { + const changeSet: GraphChangeSet = { + addNodes: [], + addEdges: [{ id: 'e1', source: 'a', target: 'b' }], + removeNodeIds: [], + removeEdgeIds: ['e0'], + }; + expect(graphChangeSetToNodeChanges(changeSet)).toEqual([]); + }); +}); + +describe('graphChangeSetToEdgeChanges (move operations)', () => { + it('translates removeEdgeIds and addEdges into remove/add EdgeChanges', () => { + const edge: Edge = { id: 'e1', source: 'a', target: 'b' }; + const changeSet: GraphChangeSet = { + addNodes: [], + addEdges: [edge], + removeNodeIds: [], + removeEdgeIds: ['e0'], + }; + expect(graphChangeSetToEdgeChanges(changeSet)).toEqual([ + { type: 'remove', id: 'e0' }, + { type: 'add', item: edge }, + ]); + }); + + it('returns an empty array for a no-op GraphChangeSet', () => { + const changeSet: GraphChangeSet = { + addNodes: [], + addEdges: [], + removeNodeIds: [], + removeEdgeIds: [], + }; + expect(graphChangeSetToEdgeChanges(changeSet)).toEqual([]); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialChangeFilters.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialChangeFilters.ts new file mode 100644 index 000000000..a1837e242 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialChangeFilters.ts @@ -0,0 +1,246 @@ +import type { Edge, EdgeChange, Node, NodeChange } from '@uipath/apollo-react/canvas/xyflow/react'; +import { PREVIEW_NODE_ID } from '../../constants'; +import { isPreviewEdge } from '../../utils/createPreviewNode'; +import type { GraphChangeSet } from '../../utils/sequential/sequential.types'; +import { SEQ_INSERTED_FLAG, SEQ_SPLIT_EDGE_ID_KEY } from './edges/sequentialInsert'; +import { SEQ_CONNECTOR_EDGE_TYPE } from './sequentialGraph.constants'; + +/** + * Change filtering is the robust guarantee that the sequential view never + * corrupts canonical state (seam 2). In the sequential view the nodes/edges + * handed to ReactFlow are DERIVED clones + connector edges, not the canonical + * graph. ReactFlow (in controlled mode) reports every store mutation through + * onNodesChange / onEdgesChange as diff changes — including position and + * dimension changes the derivation owns, and the Add Node pipeline's collision + * pass nudging clones. Forwarding those verbatim would overwrite the consumer's + * canonical positions/geometry with view-only values. + * + * These pure functions translate the derived-view change stream into the subset + * that is meaningful for canonical state: + * - position + dimension changes are DROPPED (the derivation owns geometry); + * - changes referencing synthetic rows (start bar, placeholder, preview) are + * DROPPED (they never exist in canonical state); + * - a `replace` on a real node (e.g. an inline rename via updateNodeData) is + * rewritten to merge only `data` + `selected` onto the CANONICAL node, so the + * clone's view geometry (type / position / width / draggable) can't leak; + * - `select` and `remove` on real nodes/edges pass through; + * - an insert's added node (stamped `seqInserted`) and its healed real edges + * pass through, and the canonical edge the insert split is removed. + */ + +function nodeChangeId(change: NodeChange): string | undefined { + return change.type === 'add' ? change.item.id : change.id; +} + +function isInsertedNode(node: N): boolean { + return (node.data as Record | undefined)?.[SEQ_INSERTED_FLAG] === true; +} + +/** Filters/translates node changes from the derived view for the consumer's canonical state. */ +export function forwardSequentialNodeChanges( + changes: NodeChange[], + syntheticIds: ReadonlySet, + canonicalById: ReadonlyMap +): NodeChange[] { + const out: NodeChange[] = []; + + for (const change of changes) { + const id = nodeChangeId(change); + if (id !== undefined && (id === PREVIEW_NODE_ID || syntheticIds.has(id))) { + continue; + } + + switch (change.type) { + case 'add': + // Only a genuinely inserted node (seqInserted) reaches canonical here; + // any other add is a derivation/preview artifact. + if (isInsertedNode(change.item)) out.push(change); + break; + case 'remove': + case 'select': + out.push(change); + break; + case 'replace': { + const canonical = canonicalById.get(change.id); + if (!canonical) break; + out.push({ + type: 'replace', + id: change.id, + item: { ...canonical, data: change.item.data, selected: change.item.selected }, + }); + break; + } + // Derivation owns positions and bar dimensions; never forward them. + default: + break; + } + } + + return out; +} + +function isForwardableEdgeAdd(edge: E): boolean { + return !isPreviewEdge(edge) && edge.type !== SEQ_CONNECTOR_EDGE_TYPE; +} + +function splitEdgeId(edge: Edge): string | undefined { + const value = (edge.data as Record | undefined)?.[SEQ_SPLIT_EDGE_ID_KEY]; + return typeof value === 'string' ? value : undefined; +} + +function withoutSplitMarker(edge: E): E { + if (!splitEdgeId(edge)) return edge; + const { [SEQ_SPLIT_EDGE_ID_KEY]: _marker, ...data } = edge.data as Record; + return { ...edge, data }; +} + +/** + * Canonical edges shadowed by an insert: when a new node is spliced between S and + * T, the pipeline adds S->new and new->T, so the pivot node is the id that is + * both a target and a source among the added edges. Any pre-existing canonical + * edge S->T is now redundant and must be removed. + */ +function findShadowedCanonicalEdgeIds( + addedEdges: E[], + canonicalEdges: readonly E[] +): string[] { + const targetsOf = new Map>(); + const sourcesOf = new Map>(); + for (const edge of addedEdges) { + const incoming = targetsOf.get(edge.target) ?? []; + incoming.push({ nodeId: edge.source, handleId: edge.sourceHandle }); + targetsOf.set(edge.target, incoming); + const outgoing = sourcesOf.get(edge.source) ?? []; + outgoing.push({ nodeId: edge.target, handleId: edge.targetHandle }); + sourcesOf.set(edge.source, outgoing); + } + + const shadowed = new Set(); + for (const [pivot, incomingSources] of targetsOf) { + const outgoingTargets = sourcesOf.get(pivot); + if (!outgoingTargets) continue; + for (const source of incomingSources) { + for (const target of outgoingTargets) { + for (const edge of canonicalEdges) { + if ( + edge.source === source.nodeId && + edge.target === target.nodeId && + (edge.sourceHandle ?? undefined) === (source.handleId ?? undefined) && + (edge.targetHandle ?? undefined) === (target.handleId ?? undefined) + ) { + shadowed.add(edge.id); + } + } + } + } + } + return [...shadowed]; +} + +/** Filters/translates edge changes from the derived view for the consumer's canonical state. */ +export function forwardSequentialEdgeChanges( + changes: EdgeChange[], + canonicalEdgeIds: ReadonlySet, + canonicalEdges: readonly E[] +): EdgeChange[] { + const out: EdgeChange[] = []; + const addedEdges: E[] = []; + const exactSplitEdgeIds = new Set(); + + for (const change of changes) { + switch (change.type) { + case 'add': + if (isForwardableEdgeAdd(change.item)) { + const splitId = splitEdgeId(change.item); + if (splitId) exactSplitEdgeIds.add(splitId); + const item = withoutSplitMarker(change.item); + out.push({ ...change, item }); + addedEdges.push(item); + } + break; + case 'remove': + case 'select': + if (canonicalEdgeIds.has(change.id)) out.push(change); + break; + // Connector `data` replaces are view-only; canonical edges are not replaced + // through the view. + default: + break; + } + } + + if (addedEdges.length > 0) { + const removedIds = new Set( + out.filter((change) => change.type === 'remove').map((change) => change.id) + ); + // A pending insertion carries the exact canonical edge id. Do not also run + // topology inference in that case: two parallel edges can legitimately + // have identical endpoints and handles, and inference would remove both. + const shadowedIds = + exactSplitEdgeIds.size > 0 + ? exactSplitEdgeIds + : new Set(findShadowedCanonicalEdgeIds(addedEdges, canonicalEdges)); + for (const id of shadowedIds) { + if (!removedIds.has(id)) out.push({ type: 'remove', id }); + } + } + + return out; +} + +/** + * Converts a {@link GraphChangeSet} (the tree move operations -- see + * `sequentialMoveActions.ts` / `SequentialMoveActionsContext.tsx`) into + * `NodeChange[]` for the CANONICAL `onNodesChange` callback (D10: the public + * API stays the standard change stream). + * + * Unlike `forwardSequentialNodeChanges` above, these two functions do NOT + * translate the derived VIEW's own xyflow-emitted changes (that seam exists + * to strip view-only geometry off a DERIVED clone). A move's `GraphChangeSet` + * is built directly from `moveSubtree` over the CANONICAL `{nodes, edges}` + * (see `mutations.ts`), so every node/edge here already has the real shape -- + * routing it through the clone-stripping translator would be wrong (that + * translator's own `replace` case deliberately drops `parentId`, which is + * exactly the field a cross-container move needs to carry). `moveSubtree` + * expresses "this node's parentId changed" as a paired remove+add (same id in + * both `removeNodeIds` and `addNodes`); that pairing is rewritten here as a + * `replace` so xyflow updates the existing node in place instead of a + * remove/re-add flicker. A genuinely new id in `addNodes` (never happens for + * a move today, since moves never create nodes) still round-trips correctly + * as a plain `add`. + */ +export function graphChangeSetToNodeChanges( + changeSet: GraphChangeSet +): NodeChange[] { + const removedIds = new Set(changeSet.removeNodeIds); + const changes: NodeChange[] = []; + + for (const node of changeSet.addNodes) { + if (removedIds.has(node.id)) { + changes.push({ type: 'replace', id: node.id, item: node as N }); + } else { + changes.push({ type: 'add', item: node as N }); + } + } + + const replacedIds = new Set(changeSet.addNodes.map((node) => node.id)); + for (const id of changeSet.removeNodeIds) { + if (!replacedIds.has(id)) changes.push({ type: 'remove', id }); + } + + return changes; +} + +/** Edge half of {@link graphChangeSetToNodeChanges}: a plain add/remove translation. */ +export function graphChangeSetToEdgeChanges( + changeSet: GraphChangeSet +): EdgeChange[] { + const changes: EdgeChange[] = changeSet.removeEdgeIds.map((id) => ({ + type: 'remove' as const, + id, + })); + for (const edge of changeSet.addEdges) { + changes.push({ type: 'add', item: edge as E }); + } + return changes; +} diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialGraph.constants.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialGraph.constants.ts new file mode 100644 index 000000000..e678a698f --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialGraph.constants.ts @@ -0,0 +1,34 @@ +/** + * Shared, dependency-free constants for the sequential graph derivation + * (useSequentialGraph), the connector edges, and the change filters. Kept in its + * own module so the pure change-filter helpers don't pull in the React hook. + */ + +/** Edge `type` the derivation stamps on every connector edge (the registered connector edge type). */ +export const SEQ_CONNECTOR_EDGE_TYPE = 'sequentialConnector'; + +/** + * Stable node ids for the two synthetic rows the derivation injects view-only. + * They are filtered out of onNodesChange / onEdgesChange before forwarding, and + * never appear in canonical state. The double-underscore fencing keeps them from + * colliding with any real node id. + */ +export const SEQ_START_ROW_ID = '__sequential-start__'; +export const SEQ_PLACEHOLDER_ROW_ID = '__sequential-placeholder__'; + +/** Edge ids for the synthetic connectors joining the start/placeholder bars. */ +export const SEQ_START_EDGE_ID = '__sequential-start-edge__'; +export const SEQ_PLACEHOLDER_EDGE_ID = '__sequential-placeholder-edge__'; + +/** + * Node-count ceiling under which the sequential view renders EVERY row into the + * DOM (onlyRenderVisibleElements={false}) so reading order equals row order for + * screen readers (D8). Above it, xyflow's viewport virtualization is re-enabled + * (matching the flow canvas default): rendering many hundred fixed bars at once + * makes xyflow fire updateNodeInternals for all of them on mount, and that burst + * drives xyflow's own ResizeObserver / store-rerender cycle past React's nested + * update limit. Real sequential flows sit far below this; a run this large is a + * navigable list only in the loosest sense, so trading full-DOM a11y for + * virtualization stability at that scale is the right call. Documented tradeoff. + */ +export const SEQ_FULL_RENDER_MAX_NODES = 150; diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialMoveActions.test.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialMoveActions.test.ts new file mode 100644 index 000000000..87802670f --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialMoveActions.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, it } from 'vitest'; +import { + CONTAINER_CHAIN_NODE_IDS, + CROSS_CONTAINER_BRANCH_NODE_IDS, + makeContainerChainFixture, + makeCrossContainerBranchFixture, + makeDiamondFixture, + makeWireframeFixture, + WIREFRAME_NODE_IDS, +} from '../../utils/sequential/fixtures'; +import { projectSequence } from '../../utils/sequential/projectSequence'; +import { findMoveUpSlot } from '../../utils/sequential/slotNavigation'; +import { + closesLoopToOwner, + computeSequentialMoveOptions, + getSequentialMoveSlot, + isBareBranchOwner, + resolveSlotForCommit, + resolveTailInsertionSlot, +} from './sequentialMoveActions'; + +// Every fixture here uses `uipath.control-flow.foreach` for containers and +// `uipath.control-flow.decision` for branch owners (If/Switch); this stand-in +// registry check matches that convention rather than a real manifest lookup, +// since this module only needs a `nodeId => boolean` predicate. +const isForEachContainer = (nodeId: string, nodes: { id: string; type?: string }[]) => + nodes.find((n) => n.id === nodeId)?.type === 'uipath.control-flow.foreach'; + +describe('isBareBranchOwner', () => { + it('is true for a Decision (branch owner, not a container)', () => { + const { nodes, edges } = makeWireframeFixture(); + const projection = projectSequence(nodes, edges); + expect( + isBareBranchOwner(projection, WIREFRAME_NODE_IDS.ifNode, (id) => + isForEachContainer(id, nodes) + ) + ).toBe(true); + }); + + it('is false for a For Each (container)', () => { + const { nodes, edges } = makeWireframeFixture(); + const projection = projectSequence(nodes, edges); + expect( + isBareBranchOwner(projection, WIREFRAME_NODE_IDS.forEach, (id) => + isForEachContainer(id, nodes) + ) + ).toBe(false); + }); + + it('is false for a plain leaf step', () => { + const { nodes, edges } = makeWireframeFixture(); + const projection = projectSequence(nodes, edges); + expect( + isBareBranchOwner(projection, WIREFRAME_NODE_IDS.javascript, (id) => + isForEachContainer(id, nodes) + ) + ).toBe(false); + }); +}); + +describe('computeSequentialMoveOptions', () => { + it('disables ALL FOUR directions for a bare branch owner (If/Switch) that is the sole/first child of its container (wireframe)', () => { + const { nodes, edges } = makeWireframeFixture(); + const projection = projectSequence(nodes, edges); + const isContainerNode = (id: string) => isForEachContainer(id, nodes); + + const options = computeSequentialMoveOptions( + projection, + WIREFRAME_NODE_IDS.ifNode, + isContainerNode + ); + expect(options).toEqual({ + up: undefined, + down: undefined, + indent: undefined, + outdent: undefined, + }); + }); + + it('disables ALL FOUR directions for a top-level bare branch owner, even though findMoveUpSlot ALONE would return a defined (unsound) slot', () => { + // makeDiamondFixture: A -> If {true: B, false: C} -> D. A->If is a genuine + // 'step' connector (A has a single outgoing edge), so `findMoveUpSlot` + // does NOT naturally return undefined for `If` here the way it does for + // the wireframe's `ifNode` (which is the FIRST/only child of its + // container, reached only via a 'branch-entry' connector) -- this is + // exactly the premise that makes this module's extra gate load-bearing + // for Move Up (see isBareBranchOwner's doc comment). + const { nodes, edges } = makeDiamondFixture(); + const projection = projectSequence(nodes, edges); + const isContainerNode = (id: string) => isForEachContainer(id, nodes); + + expect(findMoveUpSlot(projection, 'if')).toBeDefined(); + + const options = computeSequentialMoveOptions(projection, 'if', isContainerNode); + expect(options).toEqual({ + up: undefined, + down: undefined, + indent: undefined, + outdent: undefined, + }); + }); + + it('does not gate a container (For Each): outdent works normally for its body children', () => { + const { nodes, edges } = makeContainerChainFixture(); + const projection = projectSequence(nodes, edges); + const ids = CONTAINER_CHAIN_NODE_IDS; + const isContainerNode = (id: string) => isForEachContainer(id, nodes); + + const options = computeSequentialMoveOptions(projection, ids.y, isContainerNode); + expect(options.outdent).toBeDefined(); + }); + + it('allows move up/down for a plain leaf step (javascript in the wireframe)', () => { + const { nodes, edges } = makeWireframeFixture(); + const projection = projectSequence(nodes, edges); + const isContainerNode = (id: string) => isForEachContainer(id, nodes); + + const options = computeSequentialMoveOptions( + projection, + WIREFRAME_NODE_IDS.javascript, + isContainerNode + ); + expect(options.up).toBeDefined(); + expect(options.down).toBeDefined(); + }); + + it('does not synthesize an outdent seam for a branch-lane child', () => { + const { nodes, edges } = makeWireframeFixture(); + const projection = projectSequence(nodes, edges); + const options = computeSequentialMoveOptions(projection, WIREFRAME_NODE_IDS.thenJs, (id) => + isForEachContainer(id, nodes) + ); + expect(options.outdent).toBeUndefined(); + }); + + it('disables ALL FOUR directions for a bare branch owner nested across a container boundary (cross-container fixture)', () => { + const { nodes, edges } = makeCrossContainerBranchFixture(); + const projection = projectSequence(nodes, edges); + const ids = CROSS_CONTAINER_BRANCH_NODE_IDS; + const isContainerNode = (id: string) => isForEachContainer(id, nodes); + + const options = computeSequentialMoveOptions(projection, ids.ifNode, isContainerNode); + expect(options).toEqual({ + up: undefined, + down: undefined, + indent: undefined, + outdent: undefined, + }); + }); +}); + +describe('closesLoopToOwner', () => { + it('identifies the raw continue edge from a body tail back to its owner', () => { + const { nodes, edges } = makeWireframeFixture(); + const projection = projectSequence(nodes, edges); + expect(closesLoopToOwner(projection, WIREFRAME_NODE_IDS.thenJs, edges)).toBe(false); + // The projected owner of Then is the If branch, not the For Each container; + // branch protection above handles this shape. Verify the helper on a small + // direct loop body where the row owner and close-edge target are identical. + const directProjection = { + ...projection, + rows: projection.rows.map((row) => + row.nodeId === WIREFRAME_NODE_IDS.thenJs + ? { ...row, parentRowId: WIREFRAME_NODE_IDS.forEach } + : row + ), + }; + expect(closesLoopToOwner(directProjection, WIREFRAME_NODE_IDS.thenJs, edges)).toBe(true); + }); +}); + +describe('getSequentialMoveSlot', () => { + it('reads the matching direction from a SequentialMoveOptions bag', () => { + const options = { + up: { id: 'up' }, + down: undefined, + indent: { id: 'indent' }, + outdent: undefined, + } as const; + expect(getSequentialMoveSlot(options, 'up')).toEqual({ id: 'up' }); + expect(getSequentialMoveSlot(options, 'down')).toBeUndefined(); + expect(getSequentialMoveSlot(options, 'indent')).toEqual({ id: 'indent' }); + expect(getSequentialMoveSlot(options, 'outdent')).toBeUndefined(); + }); +}); + +describe('resolveSlotForCommit', () => { + const nodesById = new Map([ + ['n1', { id: 'n1', type: 'uipath.script', position: { x: 0, y: 0 } }], + ]); + + it('replaces a synthesized DEFAULT_SOURCE_HANDLE_ID with the registry-resolved default source handle', () => { + const slot = { id: 'slot', source: { nodeId: 'n1', handleId: 'output' } }; + const resolved = resolveSlotForCommit(slot, nodesById, () => 'real-output-handle'); + expect(resolved.source?.handleId).toBe('real-output-handle'); + }); + + it('leaves the slot unchanged when the source handle is not the synthesized default', () => { + const slot = { id: 'slot', source: { nodeId: 'n1', handleId: 'custom-handle' } }; + const resolved = resolveSlotForCommit(slot, nodesById, () => 'real-output-handle'); + expect(resolved).toBe(slot); + }); + + it('leaves the slot unchanged when the registry has nothing better to offer', () => { + const slot = { id: 'slot', source: { nodeId: 'n1', handleId: 'output' } }; + const resolved = resolveSlotForCommit(slot, nodesById, () => undefined); + expect(resolved).toBe(slot); + }); + + it('leaves a target-only slot (no source) unchanged', () => { + const slot = { id: 'slot', target: { nodeId: 'n1' } }; + const resolved = resolveSlotForCommit(slot, nodesById, () => 'real-output-handle'); + expect(resolved).toBe(slot); + }); +}); + +describe('resolveTailInsertionSlot', () => { + it('uses the terminal manifest default source handle and skips trailing orphans', () => { + const { nodes, edges } = makeWireframeFixture(); + const projection = projectSequence(nodes, edges); + projection.rows.push({ + nodeId: 'orphan', + depth: 0, + collapsible: false, + collapsed: false, + visible: true, + orphan: true, + }); + const slot = resolveTailInsertionSlot( + projection, + [...nodes, { id: 'orphan', type: 'orphan', position: { x: 0, y: 0 }, data: {} }], + (type) => (type === 'uipath.send-message' ? 'success-port' : undefined) + ); + expect(slot).toEqual({ + id: `slot:tail:${WIREFRAME_NODE_IDS.sendMessage}`, + source: { nodeId: WIREFRAME_NODE_IDS.sendMessage, handleId: 'success-port' }, + }); + }); + + it('uses the lone start node when the projected sequence is empty', () => { + const start = { + id: 'start', + type: 'uipath.trigger.manual', + position: { x: 0, y: 0 }, + data: {}, + }; + const projection = projectSequence([start], [], { isStartNode: () => true }); + + const slot = resolveTailInsertionSlot( + projection, + [start], + () => 'trigger-output', + (node) => node.id === start.id + ); + + expect(slot).toEqual({ + id: 'slot:tail:start', + source: { nodeId: 'start', handleId: 'trigger-output' }, + }); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialMoveActions.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialMoveActions.ts new file mode 100644 index 000000000..03cb4211d --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialMoveActions.ts @@ -0,0 +1,178 @@ +import type { Edge, Node } from '@uipath/apollo-react/canvas/xyflow/react'; +import { DEFAULT_SOURCE_HANDLE_ID } from '../../constants'; +import type { InsertionSlot, SequenceProjection } from '../../utils/sequential/sequential.types'; +import { + findIndentSlot, + findMoveDownSlot, + findMoveUpSlot, + findOutdentSlot, +} from '../../utils/sequential/slotNavigation'; + +/** + * Pure core for the explorer-like tree move operations (Move up/down, + * indent/outdent), kept dependency-free from React so the disable logic and + * handle-resolution are unit-testable without mounting BaseNode/BaseCanvas. + * The React binding (SequentialMoveActionsContext) wires this to the + * projection/canonical graph/registry and to onNodesChange/onEdgesChange. + */ + +export type SequentialMoveDirection = 'up' | 'down' | 'indent' | 'outdent'; + +/** A candidate move: `slot` is the target `moveSubtree` would use; `undefined` means disabled. */ +export interface SequentialMoveOptions { + up: InsertionSlot | undefined; + down: InsertionSlot | undefined; + indent: InsertionSlot | undefined; + outdent: InsertionSlot | undefined; +} + +/** + * True when `nodeId`'s row is a "bare branch owner": collapsible (per + * `SequenceRow.collapsible`, which is true for BOTH containers and branch + * owners like If/Switch) but NOT a structural container. + * + * ENGINE CONTRACT EXTENSION: the binding + * contract text calls for this gate on Outdent only. This module applies it + * to ALL FOUR operations instead, because the underlying limitation is not + * outdent-specific. `moveSubtree`'s own doc comment (utils/sequential/mutations.ts) + * and its `collectOwnSeam` helper establish that a bare branch owner has NO + * genuine "own outgoing" connector -- `projectSequence` only ever emits + * `branch-entry` connectors sourced at such a node (into its Then/Else lanes), + * never a real forward `step` past it, because the flow only "continues" + * again via the merge node reached through the branch tails. + * + * Consequence, verified against the engine's own + * `makeCrossContainerBranchFixture` test (mutations.test.ts, "relocates a bare + * branch owner WITH its lane content across a container boundary"): the ONLY + * slot shape that test exercises for moving such a node is a SOURCE-ONLY + * (append) slot with no `target`/`graphEdgeId` -- explicitly "so the splice + * only ADDS an incoming edge to `If` and never a competing outgoing one... + * splicing a new OUTGOING edge onto a bare branch owner is unsound (it always + * reads as a third lane, not a 'next step')". `findMoveUpSlot` and + * `findIndentSlot` do NOT guarantee a source-only slot (they can return a + * splice or a prepend, both of which hand the owner a new outgoing edge), so + * "Move up" and "Move into previous step" are just as unsound as Outdent for + * these nodes -- not merely "no genuine forward step" (which already, + * separately, makes `findMoveDownSlot` naturally return `undefined` with no + * extra gate needed). Disabling all four is the only interpretation that + * can't corrupt the graph. + */ +export function isBareBranchOwner( + projection: SequenceProjection, + nodeId: string, + isContainerNode: (nodeId: string) => boolean +): boolean { + const row = projection.rows.find((candidate) => candidate.nodeId === nodeId); + return !!row?.collapsible && !isContainerNode(nodeId); +} + +/** + * Computes the four move candidates for `nodeId`. A bare branch owner (see + * {@link isBareBranchOwner}) gets all four disabled regardless of what the + * individual `find*Slot` helpers return. + */ +export function computeSequentialMoveOptions( + projection: SequenceProjection, + nodeId: string, + isContainerNode: (nodeId: string) => boolean +): SequentialMoveOptions { + if (isBareBranchOwner(projection, nodeId, isContainerNode)) { + return { up: undefined, down: undefined, indent: undefined, outdent: undefined }; + } + const row = projection.rows.find((candidate) => candidate.nodeId === nodeId); + const ownerId = row?.parentRowId; + const ownerIsBareBranch = ownerId + ? isBareBranchOwner(projection, ownerId, isContainerNode) + : false; + return { + up: findMoveUpSlot(projection, nodeId), + down: findMoveDownSlot(projection, nodeId), + indent: findIndentSlot(projection, nodeId), + // A branch lane has no single semantic "after owner" seam. Synthesizing + // owner.output would create a new branch instead of moving the row out. + outdent: ownerIsBareBranch ? undefined : findOutdentSlot(projection, nodeId), + }; +} + +/** A loop-body tail cannot be outdented by splicing its close edge as a forward seam. */ +export function closesLoopToOwner( + projection: SequenceProjection, + nodeId: string, + edges: readonly Edge[] +): boolean { + const ownerId = projection.rows.find((row) => row.nodeId === nodeId)?.parentRowId; + return !!ownerId && edges.some((edge) => edge.source === nodeId && edge.target === ownerId); +} + +/** Reads the slot for a single direction (used by the keyboard handler). */ +export function getSequentialMoveSlot( + options: SequentialMoveOptions, + direction: SequentialMoveDirection +): InsertionSlot | undefined { + switch (direction) { + case 'up': + return options.up; + case 'down': + return options.down; + case 'indent': + return options.indent; + case 'outdent': + return options.outdent; + } +} + +/** + * Re-resolves a synthesized slot's SOURCE handle against the registry before + * committing (engine contract): `slotNavigation.ts`'s fallback slots stamp the + * generic `DEFAULT_SOURCE_HANDLE_ID` ('output') when there is no real edge to + * read a handle id from. When the registry knows the source node's actual + * default source handle (which may differ, e.g. a node type with no literal + * "output" handle id), that real id is used instead. A no-op when the slot's + * source handle is already something else (a real handle id copied from an + * existing edge), or when the registry has nothing better to offer. + */ +export function resolveSlotForCommit( + slot: InsertionSlot, + nodesById: ReadonlyMap, + getDefaultSourceHandleId: (nodeType: string) => string | undefined +): InsertionSlot { + if (!slot.source || slot.source.handleId !== DEFAULT_SOURCE_HANDLE_ID) return slot; + const sourceType = nodesById.get(slot.source.nodeId)?.type; + if (!sourceType) return slot; + const resolved = getDefaultSourceHandleId(sourceType); + if (!resolved || resolved === DEFAULT_SOURCE_HANDLE_ID) return slot; + return { ...slot, source: { ...slot.source, handleId: resolved } }; +} + +/** The graph shape `moveSubtree` needs for cross-container `parentId` rewrites. */ +export interface CanonicalGraph { + nodes: Node[]; + edges: Edge[]; +} + +/** Builds the terminal append slot without assuming a literal `output` handle. */ +export function resolveTailInsertionSlot( + projection: SequenceProjection | null, + nodes: readonly N[], + getDefaultSourceHandleId: (nodeType: string) => string | undefined, + isStartNode?: (node: N) => boolean +): InsertionSlot | undefined { + const topRows = projection?.rows.filter((row) => row.depth === 0 && row.visible && !row.orphan); + const last = topRows?.[topRows.length - 1]; + // Start nodes are folded into the synthetic "Workflow start" row. When the + // graph contains only one such node, use it as the terminal append source so + // the otherwise-empty "Add step" row still opens a valid insertion. + const startNodes = !last && isStartNode ? nodes.filter(isStartNode) : []; + const loneStart = startNodes.length === 1 ? startNodes[0] : undefined; + const sourceNode = last ? nodes.find((node) => node.id === last.nodeId) : loneStart; + if (!sourceNode) return undefined; + const nodeType = sourceNode.type; + return { + id: `slot:tail:${sourceNode.id}`, + source: { + nodeId: sourceNode.id, + handleId: + (nodeType ? getDefaultSourceHandleId(nodeType) : undefined) ?? DEFAULT_SOURCE_HANDLE_ID, + }, + }; +} diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/synthesizePositionsForFlow.test.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/synthesizePositionsForFlow.test.ts new file mode 100644 index 000000000..ed85225d1 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/synthesizePositionsForFlow.test.ts @@ -0,0 +1,100 @@ +import type { Edge, Node } from '@uipath/apollo-react/canvas/xyflow/react'; +import { describe, expect, it } from 'vitest'; +import { SEQ_INSERTED_FLAG } from './edges/sequentialInsert'; +import { synthesizePositionsForFlow } from './synthesizePositionsForFlow'; + +function boxesOverlap(a: Node, b: Node): boolean { + const aw = a.width ?? 96; + const ah = a.height ?? 96; + const bw = b.width ?? 96; + const bh = b.height ?? 96; + return ( + a.position.x < b.position.x + bw && + a.position.x + aw > b.position.x && + a.position.y < b.position.y + bh && + a.position.y + ah > b.position.y + ); +} + +function makeNode(id: string, x: number, y: number, extra: Partial = {}): Node { + return { + id, + type: 'uipath.script', + position: { x, y }, + data: { display: { label: id } }, + ...extra, + }; +} + +describe('synthesizePositionsForFlow', () => { + it('returns the same array reference when nothing was inserted', () => { + const nodes = [makeNode('a', 0, 0), makeNode('b', 0, 200)]; + const result = synthesizePositionsForFlow(nodes, []); + expect(result).toBe(nodes); + }); + + it('places an inserted node without overlap and clears the sequential markers', () => { + const a = makeNode('a', 0, 0, { width: 96, height: 96 }); + const inserted: Node = { + id: 'n', + type: 'uipath.slack', + position: { x: 0, y: 0 }, + draggable: false, + data: { display: { label: 'Slack' }, [SEQ_INSERTED_FLAG]: true }, + }; + const edges: Edge[] = [{ id: 'e', source: 'a', target: 'n' }]; + + const result = synthesizePositionsForFlow([a, inserted], edges); + const placed = result.find((node) => node.id === 'n')!; + + // Placed to the right of its source, no overlap with A. + expect(placed.position.x).toBeGreaterThan(a.position.x); + expect(boxesOverlap(placed, a)).toBe(false); + // Markers cleared. + expect((placed.data as Record)[SEQ_INSERTED_FLAG]).toBeUndefined(); + expect(placed.draggable).toBeUndefined(); + // Original label data preserved. + expect((placed.data as { display: { label: string } }).display.label).toBe('Slack'); + }); + + it('never moves a pre-existing node (returns them by identity)', () => { + const a = makeNode('a', 0, 0); + const b = makeNode('b', 500, 500); + const inserted: Node = { + id: 'n', + type: 'uipath.script', + position: { x: 0, y: 0 }, + data: { [SEQ_INSERTED_FLAG]: true }, + }; + const edges: Edge[] = [{ id: 'e', source: 'a', target: 'n' }]; + + const result = synthesizePositionsForFlow([a, b, inserted], edges); + + expect(result.find((n) => n.id === 'a')).toBe(a); + expect(result.find((n) => n.id === 'b')).toBe(b); + }); + + it('separates multiple inserted nodes so none overlap', () => { + const a = makeNode('a', 0, 0, { width: 96, height: 96 }); + const makeInserted = (id: string): Node => ({ + id, + type: 'uipath.script', + position: { x: 0, y: 0 }, + data: { [SEQ_INSERTED_FLAG]: true }, + }); + // Both inserted nodes have the same source, so they anchor to the same spot + // and must be pushed apart. + const edges: Edge[] = [ + { id: 'e1', source: 'a', target: 'n1' }, + { id: 'e2', source: 'a', target: 'n2' }, + ]; + + const result = synthesizePositionsForFlow([a, makeInserted('n1'), makeInserted('n2')], edges); + const n1 = result.find((n) => n.id === 'n1')!; + const n2 = result.find((n) => n.id === 'n2')!; + + expect(boxesOverlap(n1, n2)).toBe(false); + expect(boxesOverlap(n1, a)).toBe(false); + expect(boxesOverlap(n2, a)).toBe(false); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/synthesizePositionsForFlow.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/synthesizePositionsForFlow.ts new file mode 100644 index 000000000..89a678e58 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/synthesizePositionsForFlow.ts @@ -0,0 +1,95 @@ +import type { Edge, Node } from '@uipath/apollo-react/canvas/xyflow/react'; +import { DEFAULT_NODE_SIZE, GRID_SPACING } from '../../constants'; +import { getNonOverlappingPositionForDirection, snapToGrid } from '../../utils/NodeUtils'; +import { SEQ_INSERTED_FLAG } from './edges/sequentialInsert'; + +/** + * Places nodes that were inserted while in the sequential view (D4). + * + * Sequential view never persists positions: layout owns them, and canonical + * `node.position` is left untouched so toggling back to flow is lossless. The + * one exception is a node the user added while in sequential view, which has no + * meaningful flow position. Those are stamped `data.seqInserted` (by + * `sequentialOnBeforeNodeAdded`); on toggle back to flow this function gives each + * one a real, non-overlapping position and clears the flag. + * + * Guarantees (asserted by the round-trip test): + * - NO pre-existing node is moved. Existing nodes are only read as obstacles; + * their objects are returned by identity. Only `seqInserted` nodes change. + * - No inserted node overlaps another node. Placement uses + * `getNonOverlappingPositionForDirection`, which shifts ONLY the new node + * (never the obstacles) until it clears them — the opposite of + * `resolveCollisions`, which would move both and violate the guarantee. + * + * Placement anchors each inserted node just to the right of its seam source (the + * node its incoming edge comes from), matching the left-to-right bias of the + * free-form Add Node pipeline, then resolves overlap by shifting downward. + * Obstacle checks stay within the node's own coordinate frame (same `parentId`), + * so a node inserted inside a container is placed relative to its siblings. + */ +export function synthesizePositionsForFlow(nodes: Node[], edges: Edge[]): Node[] { + const hasInserted = nodes.some((node) => isSeqInserted(node)); + if (!hasInserted) return nodes; + + const gap = GRID_SPACING * 5; + const result = [...nodes]; + const indexById = new Map(result.map((node, index) => [node.id, index])); + const incomingByTarget = new Map(); + for (const edge of edges) { + if (!incomingByTarget.has(edge.target)) incomingByTarget.set(edge.target, edge); + } + + for (let i = 0; i < result.length; i++) { + const node = result[i]!; + if (!isSeqInserted(node)) continue; + + const size = { + width: node.width ?? node.measured?.width ?? DEFAULT_NODE_SIZE, + height: node.height ?? node.measured?.height ?? DEFAULT_NODE_SIZE, + }; + + const sourceEdge = incomingByTarget.get(node.id); + const sourceIndex = sourceEdge ? indexById.get(sourceEdge.source) : undefined; + const source = sourceIndex !== undefined ? result[sourceIndex] : undefined; + const sourceWidth = source?.width ?? source?.measured?.width ?? DEFAULT_NODE_SIZE; + + const anchor = source + ? { x: source.position.x + sourceWidth + gap, y: source.position.y } + : { x: 0, y: 0 }; + + // Obstacles: every other node sharing this node's coordinate frame. Same + // frame => comparing raw `position` is valid; nodes in other frames are + // irrelevant to overlap here. + const obstacles = result.filter( + (other) => + other.id !== node.id && (other.parentId ?? undefined) === (node.parentId ?? undefined) + ); + + const placed = getNonOverlappingPositionForDirection( + obstacles, + { x: snapToGrid(anchor.x), y: snapToGrid(anchor.y) }, + size, + 'right', + GRID_SPACING * 2 + ); + + result[i] = clearInsertedMarker(node, placed); + } + + return result; +} + +function isSeqInserted(node: Node): boolean { + return (node.data as Record | undefined)?.[SEQ_INSERTED_FLAG] === true; +} + +/** + * Returns a node with a real flow position and the sequential-only markers + * removed: the `seqInserted` flag is dropped and `draggable` (forced false by the + * sequential insert helper) is cleared so the node behaves normally in flow view. + */ +function clearInsertedMarker(node: Node, position: { x: number; y: number }): Node { + const { [SEQ_INSERTED_FLAG]: _flag, ...restData } = node.data as Record; + const { draggable: _draggable, ...restNode } = node; + return { ...restNode, position, data: restData }; +} diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/useCanvasViewMode.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/useCanvasViewMode.ts new file mode 100644 index 000000000..cc9097818 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/useCanvasViewMode.ts @@ -0,0 +1,26 @@ +import { useCallback } from 'react'; +import { useStorageState } from '../../hooks/useStorageState'; +import type { CanvasView } from '../../utils/sequential/sequential.types'; + +/** + * Persists the flow/sequential view choice per canvas (D11), wrapping the + * existing {@link useStorageState} localStorage helper. The host passes a stable + * `storageKey` (typically per-canvas); the choice survives reloads. Open product + * question Q4 notes a host-owned preference service can replace this later + * without touching the view components. + */ +export function useCanvasViewMode( + storageKey: string, + initial: CanvasView = 'flow' +): [CanvasView, (view: CanvasView) => void] { + const [view, setStoredView] = useStorageState(storageKey, initial); + + const setView = useCallback( + (next: CanvasView) => { + setStoredView(next); + }, + [setStoredView] + ); + + return [view, setView]; +} diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/useCanvasViewViewport.test.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/useCanvasViewViewport.test.tsx new file mode 100644 index 000000000..42ac9cb34 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/useCanvasViewViewport.test.tsx @@ -0,0 +1,94 @@ +import { act, renderHook } from '@testing-library/react'; +import type { Viewport } from '@uipath/apollo-react/canvas/xyflow/react'; +import { describe, expect, it, vi } from 'vitest'; +import { useCanvasViewViewport } from './useCanvasViewViewport'; + +const sequentialViewport: Viewport = { x: 10, y: 20, zoom: 0.8 }; +const flowViewport: Viewport = { x: 200, y: 100, zoom: 1.2 }; + +describe('useCanvasViewViewport', () => { + it('fits an initial view once when requested', () => { + const requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + const cancelAnimationFrame = vi.fn(); + vi.stubGlobal('requestAnimationFrame', requestAnimationFrame); + vi.stubGlobal('cancelAnimationFrame', cancelAnimationFrame); + + const reactFlow = { + getViewport: vi.fn(() => sequentialViewport), + setViewport: vi.fn(), + fitView: vi.fn(), + }; + const { rerender, unmount } = renderHook( + ({ view }: { view: 'flow' | 'sequential' }) => + useCanvasViewViewport({ view, reactFlow, fitOnMount: true }), + { initialProps: { view: 'sequential' as const } } + ); + + expect(reactFlow.fitView).toHaveBeenCalledOnce(); + expect(reactFlow.fitView).toHaveBeenCalledWith({ duration: 0 }); + + rerender({ view: 'sequential' }); + expect(reactFlow.fitView).toHaveBeenCalledOnce(); + + unmount(); + vi.unstubAllGlobals(); + }); + + it('restores each local viewport instead of fitting again on a return visit', () => { + let currentViewport = sequentialViewport; + const reactFlow = { + getViewport: vi.fn(() => currentViewport), + setViewport: vi.fn(), + fitView: vi.fn(), + }; + const { result, rerender } = renderHook( + ({ view }: { view: 'flow' | 'sequential' }) => useCanvasViewViewport({ view, reactFlow }), + { initialProps: { view: 'sequential' as const } } + ); + + act(() => result.current.onMove?.(null, sequentialViewport)); + rerender({ view: 'flow' }); + expect(reactFlow.fitView).toHaveBeenCalledTimes(1); + expect(reactFlow.fitView).toHaveBeenCalledWith( + expect.objectContaining({ + minZoom: sequentialViewport.zoom, + maxZoom: sequentialViewport.zoom, + }) + ); + + currentViewport = flowViewport; + act(() => result.current.onMove?.(null, flowViewport)); + rerender({ view: 'sequential' }); + + expect(reactFlow.setViewport).toHaveBeenLastCalledWith( + { ...sequentialViewport, zoom: flowViewport.zoom }, + { duration: 300 } + ); + expect(reactFlow.fitView).toHaveBeenCalledTimes(1); + }); + + it('synchronizes the local fallback with an external viewport store', () => { + const stored = new Map<'flow' | 'sequential', Viewport>(); + const externalStore = { + saveViewport: vi.fn((view: 'flow' | 'sequential', viewport: Viewport) => { + stored.set(view, viewport); + }), + getViewport: vi.fn((view: 'flow' | 'sequential') => stored.get(view)), + }; + const reactFlow = { + getViewport: vi.fn(() => sequentialViewport), + setViewport: vi.fn(), + fitView: vi.fn(), + }; + const { result } = renderHook(() => + useCanvasViewViewport({ view: 'sequential', reactFlow, externalStore }) + ); + + act(() => result.current.onMove?.(null, sequentialViewport)); + + expect(externalStore.saveViewport).toHaveBeenCalledWith('sequential', sequentialViewport); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/useCanvasViewViewport.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/useCanvasViewViewport.ts new file mode 100644 index 000000000..0fb4cb01c --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/useCanvasViewViewport.ts @@ -0,0 +1,108 @@ +import type { + Edge, + FitViewOptions, + Node, + OnMove, + ReactFlowInstance, + Viewport, +} from '@uipath/apollo-react/canvas/xyflow/react'; +import { useCallback, useEffect, useRef } from 'react'; +import type { CanvasView } from '../../utils/sequential'; + +interface ExternalViewportStore { + saveViewport: (view: CanvasView, viewport: Viewport) => void; + getViewport: (view: CanvasView) => Viewport | undefined; +} + +interface UseCanvasViewViewportArgs { + view: CanvasView; + reactFlow: Pick, 'fitView' | 'getViewport' | 'setViewport'>; + externalStore?: ExternalViewportStore; + fitViewOptions?: FitViewOptions; + fitOnMount?: boolean; +} + +interface UseCanvasViewViewportResult { + defaultViewport?: Viewport; + onMove: OnMove; +} + +/** + * Preserves an independent viewport for each presentation, even when the + * optional persisted SequentialViewProvider is absent. + */ +export function useCanvasViewViewport({ + view, + reactFlow, + externalStore, + fitViewOptions, + fitOnMount = false, +}: UseCanvasViewViewportArgs): UseCanvasViewViewportResult { + const localViewportByView = useRef>(new Map()); + const previousView = useRef(view); + const initialized = useRef(false); + + const saveViewport = useCallback( + (forView: CanvasView, viewport: Viewport) => { + localViewportByView.current.set(forView, viewport); + externalStore?.saveViewport(forView, viewport); + }, + [externalStore] + ); + + const getViewport = useCallback( + (forView: CanvasView) => + externalStore?.getViewport(forView) ?? localViewportByView.current.get(forView), + [externalStore] + ); + + const onMove = useCallback( + (_event, viewport) => saveViewport(view, viewport), + [saveViewport, view] + ); + + const defaultViewport = getViewport(view); + + useEffect(() => { + if (initialized.current) return; + + // Sequential nodes already have deterministic positions and dimensions, so + // the viewport hook only needs to fit them once after XYFlow commits them. + // From then on, onMove and the view-change effect own viewport persistence. + if (!fitOnMount || defaultViewport) { + initialized.current = true; + return; + } + + const animationFrame = requestAnimationFrame(() => { + initialized.current = true; + void reactFlow.fitView({ ...fitViewOptions, duration: 0 }); + }); + return () => cancelAnimationFrame(animationFrame); + }, [defaultViewport, fitOnMount, fitViewOptions, reactFlow]); + + useEffect(() => { + if (previousView.current === view) return; + + // The node arrays have changed by the time this effect runs, but XYFlow's + // viewport has not. Capture it for the presentation we just left before + // restoring or initially fitting the new one. + const departingViewport = reactFlow.getViewport(); + saveViewport(previousView.current, departingViewport); + previousView.current = view; + + const saved = getViewport(view); + if (saved) { + void reactFlow.setViewport({ ...saved, zoom: departingViewport.zoom }, { duration: 300 }); + } else { + void reactFlow.fitView({ + ...fitViewOptions, + minZoom: departingViewport.zoom, + maxZoom: departingViewport.zoom, + duration: 300, + }); + } + }, [view, reactFlow, fitViewOptions, getViewport, saveViewport]); + + return { defaultViewport, onMove }; +} diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/useSequentialGraph.test.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/useSequentialGraph.test.ts new file mode 100644 index 000000000..c384bd7d9 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/useSequentialGraph.test.ts @@ -0,0 +1,642 @@ +import { renderHook } from '@testing-library/react'; +import type { Edge, Node } from '@uipath/apollo-react/canvas/xyflow/react'; +import { describe, expect, it, vi } from 'vitest'; +import { + PREVIEW_NODE_ID, + SEQ_BAR_HEIGHT, + SEQ_BAR_WIDTH, + SEQ_INDENT_PX, + SEQ_PLACEHOLDER_NODE_TYPE, + SEQ_ROW_GAP, + SEQ_START_NODE_TYPE, +} from '../../constants'; +import { + makeDeepNestingFixture, + makeOrphanFixture, + makeWireframeFixture, + WIREFRAME_NODE_IDS, +} from '../../utils/sequential/fixtures'; +import { SEQUENTIAL_BAR_HANDLE_IDS } from './nodes'; +import { + SEQ_CONNECTOR_EDGE_TYPE, + SEQ_PLACEHOLDER_ROW_ID, + SEQ_START_ROW_ID, +} from './sequentialGraph.constants'; +import { deriveSequentialGraph, useSequentialGraph } from './useSequentialGraph'; + +describe('deriveSequentialGraph', () => { + it('clones every row keeping the real type, bar width, and non-draggable flag', () => { + const { nodes, edges } = makeWireframeFixture(); + const graph = deriveSequentialGraph({ nodes, edges, view: 'sequential' }); + + // 7 real rows + 2 populated-branch placeholders + synthetic start/tail. + expect(graph.nodes).toHaveLength(11); + expect(graph.nodes[0]?.id).toBe(SEQ_START_ROW_ID); + expect(graph.nodes[0]?.type).toBe(SEQ_START_NODE_TYPE); + expect(graph.nodes.some((node) => node.id === WIREFRAME_NODE_IDS.trigger)).toBe(false); + expect(graph.nodes.at(-1)?.id).toBe(SEQ_PLACEHOLDER_ROW_ID); + expect(graph.nodes.at(-1)?.type).toBe(SEQ_PLACEHOLDER_NODE_TYPE); + + const http = graph.nodes.find((node) => node.id === WIREFRAME_NODE_IDS.http)!; + expect(http.type).toBe('uipath.http-request'); + expect(http.width).toBe(SEQ_BAR_WIDTH); + expect(http.draggable).toBe(false); + expect(http.parentId).toBeUndefined(); + }); + + // Regression (perf loop): every clone AND both synthetic rows must declare an + // explicit bar height matching BaseNode's bar `computedHeight`. Without it the + // controlled `nodes` array re-applies `height: undefined` each sync, BaseNode + // re-writes it via updateNode, and the two fight through updateNodeInternals -- + // a loop that blows React's nested-update limit at ~150 nodes. + it('stamps every row with an explicit height so the store height never churns', () => { + const { nodes, edges } = makeWireframeFixture(); + const graph = deriveSequentialGraph({ nodes, edges, view: 'sequential' }); + + for (const node of graph.nodes) { + // Append plus overlays are layout-neutral (row-gap tall, no reserved row); + // every other row declares the fixed bar height. Both are explicit, which + // is what keeps the controlled store height from churning. + const isAppendOverlay = (node.data as { variant?: string } | undefined)?.variant === 'plus'; + expect(node.height).toBe(isAppendOverlay ? SEQ_ROW_GAP : SEQ_BAR_HEIGHT); + } + }); + + it('keeps clickable synthetic rows hit-testable by ReactFlow', () => { + const { nodes, edges } = makeWireframeFixture(); + const graph = deriveSequentialGraph({ + nodes, + edges, + view: 'sequential', + onAddTrigger: () => {}, + onPlaceholderAdd: () => {}, + }); + + expect(graph.nodes.find((node) => node.id === SEQ_START_ROW_ID)?.selectable).toBe(true); + expect(graph.nodes.find((node) => node.id === SEQ_PLACEHOLDER_ROW_ID)?.selectable).toBe(true); + }); + + it('renders a populated lane tail as a plus placeholder wired to the same onLaneAdd path', () => { + const { nodes, edges } = makeWireframeFixture(); + const onLaneAdd = vi.fn(); + const graph = deriveSequentialGraph({ + nodes, + edges, + view: 'sequential', + onLaneAdd, + }); + const slotId = `slot:leaf:${WIREFRAME_NODE_IDS.thenJs}`; + const placeholder = graph.nodes.find( + (node) => (node.data as { insertionSlotId?: string } | undefined)?.insertionSlotId === slotId + ); + + expect(placeholder).toMatchObject({ + type: SEQ_PLACEHOLDER_NODE_TYPE, + selectable: true, + }); + // The trailing add point of a populated lane renders as the between-step + // plus, not the dashed row an empty lane uses. + expect((placeholder?.data as { variant?: string } | undefined)?.variant).toBe('plus'); + const onAdd = (placeholder?.data as { onAdd?: () => void } | undefined)?.onAdd; + expect(onAdd).toBeTypeOf('function'); + onAdd?.(); + expect(onLaneAdd).toHaveBeenCalledWith( + expect.objectContaining({ + id: slotId, + source: { nodeId: WIREFRAME_NODE_IDS.thenJs }, + containerId: WIREFRAME_NODE_IDS.forEach, + }) + ); + const join = graph.edges.find( + (edge) => edge.source === WIREFRAME_NODE_IDS.thenJs && edge.target === placeholder?.id + ); + expect(join).toBeDefined(); + // No arrowhead into an add affordance. + expect((join?.data as { hideArrowHead?: boolean } | undefined)?.hideArrowHead).toBe(true); + }); + + it('renders the terminal tail as a plus when there are steps, and a dashed row when empty', () => { + const { nodes, edges } = makeWireframeFixture(); + const populated = deriveSequentialGraph({ + nodes, + edges, + view: 'sequential', + onPlaceholderAdd: () => {}, + }); + const populatedTail = populated.nodes.find((node) => node.id === SEQ_PLACEHOLDER_ROW_ID); + expect((populatedTail?.data as { variant?: string } | undefined)?.variant).toBe('plus'); + + const empty = deriveSequentialGraph({ + nodes: [], + edges: [], + view: 'sequential', + onPlaceholderAdd: () => {}, + }); + const emptyTail = empty.nodes.find((node) => node.id === SEQ_PLACEHOLDER_ROW_ID); + expect((emptyTail?.data as { variant?: string } | undefined)?.variant).toBe('row'); + }); + + it('renders an empty container body as a dashed Add step row, not a plus', () => { + const nodes: Node[] = [{ id: 'loop', type: 'loop', position: { x: 0, y: 0 }, data: {} }]; + const graph = deriveSequentialGraph({ + nodes, + edges: [], + view: 'sequential', + isContainerNode: (node) => node.id === 'loop', + onLaneAdd: () => {}, + }); + const bodyPlaceholder = graph.nodes.find((node) => + (node.data as { insertionSlotId?: string } | undefined)?.insertionSlotId?.startsWith( + 'slot:lane:loop' + ) + ); + expect(bodyPlaceholder).toBeDefined(); + expect((bodyPlaceholder?.data as { variant?: string } | undefined)?.variant).toBe('row'); + }); + + it('flattens container children and indents them by depth', () => { + const { nodes, edges } = makeWireframeFixture(); + const graph = deriveSequentialGraph({ nodes, edges, view: 'sequential' }); + + // The If is a body child of For Each -> depth 1 -> x = 1 * indent, parentId cleared. + const ifNode = graph.nodes.find((node) => node.id === WIREFRAME_NODE_IDS.ifNode)!; + expect(ifNode.parentId).toBeUndefined(); + expect(ifNode.position.x).toBe(SEQ_INDENT_PX); + }); + + it('applies custom node dimensions, indentation, and vertical gap to the real layout', () => { + const { nodes, edges } = makeWireframeFixture(); + const customWidth = 640; + const customHeight = 80; + const customIndent = 128; + const customGap = 80; + const graph = deriveSequentialGraph({ + nodes, + edges, + view: 'sequential', + layoutOptions: { + barWidth: customWidth, + barHeight: customHeight, + indent: customIndent, + rowGap: customGap, + }, + }); + + expect(graph.nodes.every((node) => node.width === customWidth)).toBe(true); + expect(graph.nodes.every((node) => node.height === customHeight)).toBe(true); + expect(graph.nodes.find((node) => node.id === WIREFRAME_NODE_IDS.ifNode)?.position.x).toBe( + customIndent + ); + const httpY = graph.nodes.find((node) => node.id === WIREFRAME_NODE_IDS.http)?.position.y ?? 0; + const javascriptY = + graph.nodes.find((node) => node.id === WIREFRAME_NODE_IDS.javascript)?.position.y ?? 0; + expect(javascriptY - httpY).toBe(customHeight + customGap); + }); + + it('preserves the canonical data reference and selection passthrough', () => { + const { nodes, edges } = makeWireframeFixture(); + const selected = nodes.map((node) => + node.id === WIREFRAME_NODE_IDS.http ? { ...node, selected: true } : node + ); + const canonicalHttp = selected.find((node) => node.id === WIREFRAME_NODE_IDS.http)!; + const graph = deriveSequentialGraph({ nodes: selected, edges, view: 'sequential' }); + + const http = graph.nodes.find((node) => node.id === WIREFRAME_NODE_IDS.http)!; + expect(http.data).toBe(canonicalHttp.data); + expect(http.selected).toBe(true); + }); + + it('builds connector edges with the bar handle ids plus synthetic joins', () => { + const { nodes, edges } = makeWireframeFixture(); + const graph = deriveSequentialGraph({ nodes, edges, view: 'sequential' }); + + for (const edge of graph.edges) { + expect(edge.type).toBe(SEQ_CONNECTOR_EDGE_TYPE); + expect(edge.sourceHandle).toBe(SEQUENTIAL_BAR_HANDLE_IDS.source); + // Branch/container-entry connectors enter the child's mid-left; every + // other kind drops into the top handle. + expect(edge.targetHandle).toBe( + edge.data?.kind === 'branch-entry' + ? SEQUENTIAL_BAR_HANDLE_IDS.branchTarget + : SEQUENTIAL_BAR_HANDLE_IDS.target + ); + } + // Every projection connector + 2 synthetic joins (start->first, last->placeholder). + expect(graph.edges).toHaveLength((graph.projection?.connectors.length ?? 0) + 2); + expect(graph.edges.some((edge) => edge.source === SEQ_START_ROW_ID)).toBe(true); + expect(graph.edges.some((edge) => edge.target === SEQ_PLACEHOLDER_ROW_ID)).toBe(true); + + const startEdge = graph.edges.find((edge) => edge.source === SEQ_START_ROW_ID); + expect(startEdge?.data?.hideArrowHead).toBe(false); + expect(startEdge?.data?.slot).toEqual({ + id: `slot:head:${WIREFRAME_NODE_IDS.http}`, + target: { nodeId: WIREFRAME_NODE_IDS.http }, + }); + }); + + it('renders a split preview with the same projected connectors as the committed row', () => { + const { nodes, edges } = makeWireframeFixture(); + const base = deriveSequentialGraph({ nodes, edges, view: 'sequential' }); + const slot = base.projection?.connectors.find( + (connector) => + connector.sourceRowId === WIREFRAME_NODE_IDS.http && + connector.targetRowId === WIREFRAME_NODE_IDS.javascript + )?.slot; + expect(slot).toBeDefined(); + + const graph = deriveSequentialGraph({ nodes, edges, view: 'sequential', insertSlot: slot }); + const previewEdges = graph.edges.filter( + (edge) => edge.source === PREVIEW_NODE_ID || edge.target === PREVIEW_NODE_ID + ); + + expect(previewEdges).toHaveLength(2); + expect(previewEdges.every((edge) => edge.data?.preview === true)).toBe(true); + expect(previewEdges.every((edge) => edge.data?.slot === undefined)).toBe(true); + expect(previewEdges.every((edge) => edge.style?.opacity === 0.8)).toBe(true); + }); + + it('opens a real gap before the first row without moving the start bar into the preview', () => { + const { nodes, edges } = makeWireframeFixture(); + const base = deriveSequentialGraph({ nodes, edges, view: 'sequential' }); + const startEdge = base.edges.find((edge) => edge.source === SEQ_START_ROW_ID); + const slot = startEdge?.data?.slot; + expect(slot).toBeDefined(); + + const graph = deriveSequentialGraph({ nodes, edges, view: 'sequential', insertSlot: slot }); + const start = graph.nodes.find((node) => node.id === SEQ_START_ROW_ID)!; + const previewY = graph.layout?.positions.get(PREVIEW_NODE_ID)?.y; + const firstY = graph.layout?.positions.get(WIREFRAME_NODE_IDS.http)?.y; + + expect(previewY).toBeDefined(); + expect(firstY).toBeDefined(); + expect(start.position.y).toBe((previewY ?? 0) - (SEQ_BAR_HEIGHT + SEQ_ROW_GAP)); + expect(firstY).toBe((previewY ?? 0) + SEQ_BAR_HEIGHT + SEQ_ROW_GAP); + expect(new Set([start.position.y, previewY, firstY]).size).toBe(3); + }); + + it('keeps a branch preview on the branch-entry elbow and left target handle', () => { + const { nodes, edges } = makeWireframeFixture(); + const base = deriveSequentialGraph({ nodes, edges, view: 'sequential' }); + const slot = base.projection?.connectors.find( + (connector) => + connector.sourceRowId === WIREFRAME_NODE_IDS.ifNode && + connector.targetRowId === WIREFRAME_NODE_IDS.thenJs + )?.slot; + expect(slot).toBeDefined(); + + const graph = deriveSequentialGraph({ nodes, edges, view: 'sequential', insertSlot: slot }); + const incoming = graph.edges.find((edge) => edge.target === PREVIEW_NODE_ID); + + expect(incoming?.data?.kind).toBe('branch-entry'); + expect(incoming?.targetHandle).toBe(SEQUENTIAL_BAR_HANDLE_IDS.branchTarget); + expect(incoming?.data?.waypoints).toHaveLength(2); + }); + + it('draws the tail preview through to the placeholder without materializing that visual join', () => { + const { nodes, edges } = makeWireframeFixture(); + const graph = deriveSequentialGraph({ + nodes, + edges, + view: 'sequential', + insertSlot: { + id: 'slot:tail', + source: { nodeId: WIREFRAME_NODE_IDS.sendMessage, handleId: 'output' }, + }, + }); + const placeholderJoin = graph.edges.find( + (edge) => edge.source === PREVIEW_NODE_ID && edge.target === SEQ_PLACEHOLDER_ROW_ID + ); + + expect(placeholderJoin).toMatchObject({ + sourceHandle: 'output', + data: { preview: true, ignorePreviewConnection: true }, + }); + }); + + it('places the placeholder before de-emphasized orphan rows', () => { + const { nodes, edges } = makeOrphanFixture(); + const graph = deriveSequentialGraph({ nodes, edges, view: 'sequential' }); + expect(graph.nodes.map((node) => node.id)).toEqual([ + SEQ_START_ROW_ID, + 'a', + 'b', + SEQ_PLACEHOLDER_ROW_ID, + 'z', + ]); + expect(graph.nodes.at(-1)?.className).toContain('opacity-60'); + expect(graph.nodes.find((node) => node.id === SEQ_PLACEHOLDER_ROW_ID)?.position.y).toBeLessThan( + graph.nodes.find((node) => node.id === 'z')?.position.y ?? 0 + ); + }); + + it('places the placeholder below a trailing container body, not inside it', () => { + // root -> c1 (container: c2 -> leaf). c1 is the last top-level row, but its + // body (c2, leaf and its branch-tail placeholder) extends below it; the + // terminal placeholder must sit under the whole stack. + const { nodes, edges } = makeDeepNestingFixture(); + const graph = deriveSequentialGraph({ nodes, edges, view: 'sequential' }); + const placeholderY = + graph.nodes.find((node) => node.id === SEQ_PLACEHOLDER_ROW_ID)?.position.y ?? 0; + const leafY = graph.nodes.find((node) => node.id === 'leaf')?.position.y ?? 0; + const c1Y = graph.nodes.find((node) => node.id === 'c1')?.position.y ?? 0; + expect(leafY).toBeGreaterThan(c1Y); + expect(placeholderY).toBeGreaterThan(leafY); + }); + + it('passes the canonical graph through unchanged in flow view', () => { + const { nodes, edges } = makeWireframeFixture(); + const graph = deriveSequentialGraph({ nodes, edges, view: 'flow' }); + // deriveSequentialGraph always projects; the hook is what short-circuits flow + // view. This asserts the projection still runs without throwing. + expect(graph.projection).not.toBeNull(); + }); + + it('keeps preview edges on guaranteed bar handles while carrying canonical handles for filtering', () => { + const nodes: Node[] = [ + { id: 'javascript', type: 'script', position: { x: 0, y: 0 }, data: {} }, + { id: 'for-each', type: 'foreach', position: { x: 0, y: 100 }, data: {} }, + ]; + const edges: Edge[] = [ + { + id: 'javascript-success-for-each', + source: 'javascript', + sourceHandle: 'success', + target: 'for-each', + targetHandle: 'input', + }, + ]; + const getBranchHandles = (node: Node) => + node.id === 'javascript' ? [{ id: 'error', label: 'Error' }] : []; + const base = deriveSequentialGraph({ + nodes, + edges, + view: 'sequential', + getBranchHandles, + }); + const slot = base.projection?.connectors.find( + (connector) => connector.sourceRowId === 'javascript' && connector.targetRowId === 'for-each' + )?.slot; + + expect(slot).toMatchObject({ + source: { nodeId: 'javascript', handleId: 'success' }, + target: { nodeId: 'for-each', handleId: 'input' }, + }); + + const preview = deriveSequentialGraph({ + nodes, + edges, + view: 'sequential', + getBranchHandles, + insertSlot: slot, + }); + const incoming = preview.edges.find( + (edge) => edge.source === 'javascript' && edge.target === PREVIEW_NODE_ID + ); + const outgoing = preview.edges.find( + (edge) => edge.source === PREVIEW_NODE_ID && edge.target === 'for-each' + ); + + expect(incoming).toMatchObject({ + sourceHandle: SEQUENTIAL_BAR_HANDLE_IDS.source, + targetHandle: 'input', + data: { previewConnectionHandleId: 'success' }, + }); + expect(outgoing).toMatchObject({ + sourceHandle: 'output', + targetHandle: SEQUENTIAL_BAR_HANDLE_IDS.target, + data: { previewConnectionHandleId: 'input' }, + }); + }); +}); + +describe('presentation-only node preservation', () => { + it('omits excluded nodes and their edges from sequential without mutating canonical state', () => { + const { nodes, edges } = makeWireframeFixture(); + const sticky: Node = { + id: 'note', + type: 'stickyNote', + position: { x: 900, y: 700 }, + data: { content: 'Keep me in Flow' }, + }; + const annotationEdge: Edge = { + id: 'note-http', + source: sticky.id, + target: WIREFRAME_NODE_IDS.http, + }; + + const graph = deriveSequentialGraph({ + nodes: [...nodes, sticky], + edges: [...edges, annotationEdge], + view: 'sequential', + isSequenceNode: (node) => node.type !== 'stickyNote', + }); + + expect(graph.nodes.some((node) => node.id === sticky.id)).toBe(false); + expect(graph.edges.some((edge) => edge.id === annotationEdge.id)).toBe(false); + expect(sticky.position).toEqual({ x: 900, y: 700 }); + expect(graph.projection?.rows.filter((row) => !row.lanePlaceholder)).toHaveLength(7); + }); +}); + +describe('useSequentialGraph memoization (D12)', () => { + it('hands routed preview connectors to ReactFlow while an insertion slot is active', () => { + const { nodes, edges } = makeWireframeFixture(); + const base = deriveSequentialGraph({ nodes, edges, view: 'sequential' }); + const slot = base.projection?.connectors.find( + (connector) => + connector.sourceRowId === WIREFRAME_NODE_IDS.http && + connector.targetRowId === WIREFRAME_NODE_IDS.javascript + )?.slot; + expect(slot).toBeDefined(); + + const { result } = renderHook(() => + useSequentialGraph({ nodes, edges, view: 'sequential', insertSlot: slot }) + ); + const previewEdges = result.current.edges.filter( + (edge) => edge.source === PREVIEW_NODE_ID || edge.target === PREVIEW_NODE_ID + ); + + expect(previewEdges).toHaveLength(2); + expect(previewEdges.every((edge) => edge.data?.preview === true)).toBe(true); + expect(result.current.layout?.positions.get(PREVIEW_NODE_ID)).toBeDefined(); + }); + + it('reuses the connector-edge array across data-only changes but reflects new data on nodes', () => { + const { nodes, edges } = makeWireframeFixture(); + const { result, rerender } = renderHook( + (props: { nodes: Node[]; edges: Edge[] }) => + useSequentialGraph({ nodes: props.nodes, edges: props.edges, view: 'sequential' }), + { initialProps: { nodes, edges } } + ); + + const firstEdges = result.current.edges; + const firstNodes = result.current.nodes; + + // Data-only change: rename one node (structure/fingerprint unchanged). + const renamed = nodes.map((node) => + node.id === WIREFRAME_NODE_IDS.http + ? { ...node, data: { display: { label: 'Renamed' } } } + : node + ); + rerender({ nodes: renamed, edges }); + + // Edges are reference-stable across data-only changes (D12). + expect(result.current.edges).toBe(firstEdges); + // Nodes recompute so the rename is reflected. + expect(result.current.nodes).not.toBe(firstNodes); + const http = result.current.nodes.find((node) => node.id === WIREFRAME_NODE_IDS.http)!; + expect((http.data as { display: { label: string } }).display.label).toBe('Renamed'); + }); + + it('reuses every unchanged derived node across a single-node selection update', () => { + const { nodes, edges } = makeWireframeFixture(); + const { result, rerender } = renderHook( + (props: { nodes: Node[] }) => + useSequentialGraph({ nodes: props.nodes, edges, view: 'sequential' }), + { initialProps: { nodes } } + ); + const firstNodesById = new Map(result.current.nodes.map((node) => [node.id, node])); + const selectedId = WIREFRAME_NODE_IDS.http; + const selected = nodes.map((node) => + node.id === selectedId ? { ...node, selected: true } : node + ); + + rerender({ nodes: selected }); + + for (const node of result.current.nodes) { + if (node.id === selectedId) { + expect(node).not.toBe(firstNodesById.get(node.id)); + expect(node.selected).toBe(true); + } else { + expect(node).toBe(firstNodesById.get(node.id)); + } + } + }); + + it('reuses projection when equivalent registry predicates change identity on rename', () => { + const { nodes, edges } = makeWireframeFixture(); + const { result, rerender } = renderHook( + (props: { nodes: Node[]; predicateVersion: number }) => + useSequentialGraph({ + nodes: props.nodes, + edges, + view: 'sequential', + isSequenceEdge: (edge) => !!edge.id && props.predicateVersion >= 0, + isStartNode: (node) => node.type === 'uipath.first-run' && props.predicateVersion >= 0, + resolveBranchLabel: (_nodeId, handleId) => + props.predicateVersion >= 0 ? handleId : handleId, + }), + { initialProps: { nodes, predicateVersion: 0 } } + ); + const firstProjection = result.current.projection; + const renamed = nodes.map((node) => + node.id === WIREFRAME_NODE_IDS.http + ? { ...node, data: { display: { label: 'Renamed' } } } + : node + ); + + rerender({ nodes: renamed, predicateVersion: 1 }); + expect(result.current.projection).toBe(firstProjection); + }); + + it('rebuilds the connector-edge array on a structural change', () => { + const { nodes, edges } = makeWireframeFixture(); + const { result, rerender } = renderHook( + (props: { nodes: Node[]; edges: Edge[] }) => + useSequentialGraph({ nodes: props.nodes, edges: props.edges, view: 'sequential' }), + { initialProps: { nodes, edges } } + ); + const firstEdges = result.current.edges; + + // Structural change: drop an edge. + const fewerEdges = edges.slice(0, -1); + rerender({ nodes, edges: fewerEdges }); + + expect(result.current.edges).not.toBe(firstEdges); + }); + + it('refreshes connector labels when edge data changes without a topology change', () => { + const { nodes, edges } = makeWireframeFixture(); + const { result, rerender } = renderHook( + (props: { edges: Edge[] }) => + useSequentialGraph({ nodes, edges: props.edges, view: 'sequential' }), + { initialProps: { edges } } + ); + const firstProjection = result.current.projection; + const relabeled = edges.map((edge) => + edge.id === 'e-if-then' ? { ...edge, data: { ...edge.data, label: 'When true' } } : edge + ); + + rerender({ edges: relabeled }); + expect(result.current.projection).not.toBe(firstProjection); + expect( + result.current.projection?.connectors.find( + (connector) => + connector.sourceRowId === WIREFRAME_NODE_IDS.ifNode && + connector.targetRowId === WIREFRAME_NODE_IDS.thenJs + )?.label + ).toBe('When true'); + }); + + it('returns the canonical graph in flow view', () => { + const { nodes, edges } = makeWireframeFixture(); + const { result } = renderHook(() => useSequentialGraph({ nodes, edges, view: 'flow' })); + expect(result.current.nodes).toBe(nodes); + expect(result.current.edges).toBe(edges); + expect(result.current.projection).toBeNull(); + }); + + it('exposes the layout result so consumers (e.g. SequentialGutter) can read row positions', () => { + const { nodes, edges } = makeWireframeFixture(); + const { result } = renderHook(() => useSequentialGraph({ nodes, edges, view: 'sequential' })); + expect(result.current.layout).not.toBeNull(); + expect(result.current.layout?.positions.get(WIREFRAME_NODE_IDS.http)).toBeDefined(); + }); + + it('returns layout: null in flow view', () => { + const { nodes, edges } = makeWireframeFixture(); + const { result } = renderHook(() => useSequentialGraph({ nodes, edges, view: 'flow' })); + expect(result.current.layout).toBeNull(); + }); +}); + +describe('useSequentialGraph step aria labeling (D8)', () => { + it('stamps node.ariaLabel as "Step N of Total: Label" on every numbered row', () => { + const { nodes, edges } = makeWireframeFixture(); + const { result } = renderHook(() => useSequentialGraph({ nodes, edges, view: 'sequential' })); + + // The wireframe fixture has 7 numbered rows (see fixtures.ts's doc comment). + const http = result.current.nodes.find((node) => node.id === WIREFRAME_NODE_IDS.http)!; + expect(http.ariaLabel).toBe('Step 1 of 7: HTTP Request'); + + const javascript = result.current.nodes.find( + (node) => node.id === WIREFRAME_NODE_IDS.javascript + )!; + expect(javascript.ariaLabel).toBe('Step 2 of 7: Javascript'); + }); + + it('does not stamp an ariaLabel on the synthetic start/placeholder rows', () => { + const { nodes, edges } = makeWireframeFixture(); + const { result } = renderHook(() => useSequentialGraph({ nodes, edges, view: 'sequential' })); + + expect(result.current.nodes[0]?.id).toBe(SEQ_START_ROW_ID); + expect(result.current.nodes[0]?.ariaLabel).toBeUndefined(); + expect(result.current.nodes.at(-1)?.id).toBe(SEQ_PLACEHOLDER_ROW_ID); + expect(result.current.nodes.at(-1)?.ariaLabel).toBeUndefined(); + }); + + it('keeps the total stable when a container collapses (D7 numbering stability)', () => { + const { nodes, edges } = makeWireframeFixture(); + const { result } = renderHook(() => + useSequentialGraph({ + nodes, + edges, + view: 'sequential', + collapsedStepIds: new Set([WIREFRAME_NODE_IDS.forEach]), + }) + ); + + const http = result.current.nodes.find((node) => node.id === WIREFRAME_NODE_IDS.http)!; + expect(http.ariaLabel).toBe('Step 1 of 7: HTTP Request'); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/useSequentialGraph.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/useSequentialGraph.ts new file mode 100644 index 000000000..a93f2bde1 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/useSequentialGraph.ts @@ -0,0 +1,762 @@ +import type { Edge, Node } from '@uipath/apollo-react/canvas/xyflow/react'; +import { useMemo, useRef } from 'react'; +import { useSafeLingui } from '../../../i18n'; +import { + DEFAULT_SOURCE_HANDLE_ID, + DEFAULT_TARGET_HANDLE_ID, + PREVIEW_NODE_ID, + SEQ_BAR_HEIGHT, + SEQ_BAR_WIDTH, + SEQ_PLACEHOLDER_NODE_TYPE, + SEQ_ROW_GAP, + SEQ_START_NODE_TYPE, +} from '../../constants'; +import type { NodeTypeRegistry } from '../../core'; +import { useOptionalNodeTypeRegistry } from '../../core'; +import type { InstanceDisplayConfig } from '../../schema/node-instance'; +import { resolveDisplay } from '../../utils/manifest-resolver'; +import { sequenceFingerprint } from '../../utils/sequential/fingerprint'; +import { layoutSequence } from '../../utils/sequential/layoutSequence'; +import { projectionWithPreviewRow } from '../../utils/sequential/previewRow'; +import { projectSequence } from '../../utils/sequential/projectSequence'; +import type { + CanvasView, + InsertionSlot, + LayoutSequenceOptions, + SequenceLayout, + SequenceProjection, +} from '../../utils/sequential/sequential.types'; +import { EMPTY_WAYPOINTS } from '../Edges/shared/constants'; +import type { SequentialConnectorData } from './edges/SequentialConnectorEdge.types'; +import { SEQUENTIAL_BAR_HANDLE_IDS } from './nodes'; +import type { SequentialPlaceholderNodeData } from './nodes/SequentialPlaceholderNode'; +import type { SequentialStartNodeData } from './nodes/SequentialStartNode'; +import { + SEQ_CONNECTOR_EDGE_TYPE, + SEQ_PLACEHOLDER_EDGE_ID, + SEQ_PLACEHOLDER_ROW_ID, + SEQ_START_EDGE_ID, + SEQ_START_ROW_ID, +} from './sequentialGraph.constants'; + +/** The translate function `useSafeLingui()._` returns; used to type the aria-label formatter. */ +type TranslateFn = ReturnType['_']; + +const EMPTY_COLLAPSED: ReadonlySet = new Set(); + +/** + * Stable string over a predicate applied to each item, used only as a memo + * guard so the projection recomputes when predicate *results* (not identity) + * change. When the predicate is absent, every item falls back to `whenAbsent`. + */ +function predicateFingerprint( + items: readonly T[], + predicate: ((item: T) => boolean) | undefined, + whenAbsent: 0 | 1 +): string { + return items + .map((item) => `${item.id}:${predicate ? (predicate(item) ? 1 : 0) : whenAbsent}`) + .join('|'); +} + +/** The ids of the two synthetic rows, for filtering them out of change callbacks. */ +export const SEQ_SYNTHETIC_ROW_IDS: ReadonlySet = new Set([ + SEQ_START_ROW_ID, + SEQ_PLACEHOLDER_ROW_ID, +]); + +export interface UseSequentialGraphArgs { + nodes: N[]; + edges: E[]; + /** Only the 'sequential' view derives; 'flow' passes the canonical graph through. */ + view: CanvasView; + collapsedStepIds?: ReadonlySet; + /** Wired onto the synthetic start bar's "Add trigger" button. */ + onAddTrigger?: () => void; + /** Wired onto the terminal placeholder's click (opens Add Node for the tail slot). */ + onPlaceholderAdd?: () => void; + /** Registry-driven predicate excluding artifact/resource edges (see projectSequence). */ + isSequenceEdge?: (edge: E) => boolean; + /** Excludes presentation-only nodes while retaining them in the canonical graph. */ + isSequenceNode?: (node: N) => boolean; + /** Registry-driven trigger predicate; matching nodes collapse into the synthetic start row. */ + isStartNode?: (node: N) => boolean; + /** Registry-driven predicate preserving empty manifest containers. */ + isContainerNode?: (node: N) => boolean; + resolveBranchLabel?: (nodeId: string, handleId: string) => string; + /** Registry-driven branch-lane handles for a parent node (see projectSequence). */ + getBranchHandles?: (node: N) => { id: string; label: string }[]; + /** Opens the Add Node panel for an empty branch lane's "+ Add step" placeholder. */ + onLaneAdd?: (slot: InsertionSlot) => void; + /** + * The slot whose Add Node panel is currently open, if any. While set, the + * layout re-runs with a synthetic preview row spliced in at the slot, opening + * a one-row gap and re-routing every connector from the shifted geometry. Only + * the layout memo depends on it; the projection memo stays fingerprint-keyed + * (D12), so this never re-projects. + */ + insertSlot?: InsertionSlot; + /** Geometry overrides applied consistently to rows, connectors, gutter, and preview layout. */ + layoutOptions?: LayoutSequenceOptions; +} + +export interface SequentialGraph { + nodes: N[]; + edges: E[]; + projection: SequenceProjection | null; + /** + * The geometry pass backing `nodes`' positions. Exposed so consumers like + * `SequentialGutter` (WS5) can align the step-number gutter to the exact + * same coordinates without re-deriving them or reading node DOM. + */ + layout: SequenceLayout | null; +} + +/** + * Derives the sequential view's node/edge arrays from the canonical graph (D4): + * - each projected row becomes a clone of its canonical node that KEEPS its real + * `type` (so BaseNode resolves the real manifest and the bar looks identical to + * the card), takes its position from `layoutSequence`, is stamped + * `width = SEQ_BAR_WIDTH`, flattened (`parentId` cleared so the absolute layout + * positions are honored), made non-draggable, and hidden when a collapsed + * ancestor parks it. `data` and `selected` are passed through by reference so + * execution/validation contexts, memoization, and selection all keep working; + * - a synthetic "Workflow start" bar is injected above the first row and a + * terminal "Add step" placeholder below the last top-level row (both view-only, + * filtered from the change callbacks); + * - each projection connector becomes a reference-stable + * {@link SequentialConnectorData} edge, plus synthetic connectors joining the + * start/placeholder bars. + * + * This is a PURE function so the round-trip/derivation tests exercise it without + * mounting xyflow. The hook {@link useSequentialGraph} memoizes it per D12. + */ +export function deriveSequentialGraph( + args: UseSequentialGraphArgs +): SequentialGraph { + const { nodes, edges } = args; + const collapsed = args.collapsedStepIds ?? EMPTY_COLLAPSED; + const sequenceNodes = args.isSequenceNode ? nodes.filter(args.isSequenceNode) : nodes; + const sequenceNodeIds = new Set(sequenceNodes.map((node) => node.id)); + const sequenceEdges = edges.filter( + (edge) => sequenceNodeIds.has(edge.source) && sequenceNodeIds.has(edge.target) + ); + const projection = projectSequence(sequenceNodes, sequenceEdges, { + collapsedStepIds: collapsed, + isSequenceEdge: args.isSequenceEdge as ((edge: Edge) => boolean) | undefined, + isStartNode: args.isStartNode as ((node: Node) => boolean) | undefined, + isContainerNode: args.isContainerNode as ((node: Node) => boolean) | undefined, + resolveBranchLabel: args.resolveBranchLabel, + getBranchHandles: args.getBranchHandles as + | ((node: Node) => { id: string; label: string }[]) + | undefined, + }); + const renderedProjection = args.insertSlot + ? projectionWithPreviewRow(projection, args.insertSlot) + : projection; + const layout = layoutSequence(renderedProjection, args.layoutOptions); + + const nodesById = new Map(nodes.map((node) => [node.id, node] as const)); + const seqNodes = buildSequentialNodes( + projection, + layout, + nodesById, + { + onAddTrigger: args.onAddTrigger, + onPlaceholderAdd: args.onPlaceholderAdd, + onLaneAdd: args.onLaneAdd, + }, + args.layoutOptions + ); + const seqEdges = buildSequentialEdges(renderedProjection, layout); + + return { + nodes: seqNodes as unknown as N[], + edges: seqEdges as unknown as E[], + projection, + layout, + }; +} + +interface SyntheticCallbacks { + onAddTrigger?: () => void; + onPlaceholderAdd?: () => void; + /** Wired onto each empty branch-lane placeholder's click, with its own slot. */ + onLaneAdd?: (slot: InsertionSlot) => void; +} + +function buildSequentialNodes( + projection: SequenceProjection, + layout: SequenceLayout, + nodesById: ReadonlyMap, + callbacks: SyntheticCallbacks, + layoutOptions?: LayoutSequenceOptions +): Node[] { + const barWidth = layoutOptions?.barWidth ?? SEQ_BAR_WIDTH; + const barHeight = layoutOptions?.barHeight ?? SEQ_BAR_HEIGHT; + const rowGap = layoutOptions?.rowGap ?? SEQ_ROW_GAP; + const pitch = barHeight + rowGap; + const clones: Node[] = []; + const orphanClones: Node[] = []; + let firstTopY: number | undefined; + let firstOrphanY: number | undefined; + // Lowest visible, non-orphan row (any depth), so the terminal placeholder can + // sit below the WHOLE stack rather than just below the last top-level row. + let maxVisibleY: number | undefined; + + for (const row of projection.rows) { + // Empty branch-lane placeholder: a synthetic dashed "+ Add step" bar with no + // canonical node. Rendered via the placeholder node type, entered mid-left by + // its branch-entry connector (Part A), and clicking it appends the first node + // into that lane via the carried slot. + if (row.lanePlaceholder) { + const laneSlot = row.lanePlaceholder; + const onLaneAdd = callbacks.onLaneAdd; + clones.push({ + id: row.nodeId, + type: SEQ_PLACEHOLDER_NODE_TYPE, + position: layout.positions.get(row.nodeId) ?? { x: 0, y: 0 }, + width: barWidth, + // An append plus is an overlay in the row gap (rowGap tall, no reserved + // row); an empty lane/body is a full dashed row. + height: row.placeholderKind === 'append' ? rowGap : barHeight, + draggable: false, + // ReactFlow sets pointer-events:none on wrappers that are neither + // selectable nor draggable and have no node-level click handler. Keep + // the row hit-testable whenever its inner Add-step button is active; + // that button stops propagation, so this does not select the row. + selectable: onLaneAdd !== undefined, + hidden: !row.visible, + data: { + onAdd: onLaneAdd ? () => onLaneAdd(laneSlot) : undefined, + insertionSlotId: laneSlot.id, + // Empty lane/body renders the dashed row; a populated lane's trailing + // add point renders the same quiet plus used between steps. + variant: row.placeholderKind === 'append' ? 'plus' : 'row', + } satisfies SequentialPlaceholderNodeData, + }); + continue; + } + const canonical = nodesById.get(row.nodeId); + if (!canonical) continue; + const position = layout.positions.get(row.nodeId) ?? { x: 0, y: 0 }; + const clone: Node = { + ...canonical, + position, + width: barWidth, + // Declare the layout-owned bar height up front so it matches BaseNode's + // bar `computedHeight`. Without it the controlled `nodes` + // array re-applies `height: undefined` on every sync, BaseNode's + // height write-back re-sets it via `updateNode`, and the two fight + // through `updateNodeInternals` -- a loop that self-settles at small + // graphs but blows React's nested-update limit at ~150 nodes. + height: barHeight, + draggable: false, + hidden: !row.visible, + // Flatten container nesting: layout positions are absolute, so a residual + // parentId would double-offset the clone. + parentId: undefined, + extent: undefined, + expandParent: undefined, + className: row.orphan + ? [canonical.className, 'opacity-60'].filter(Boolean).join(' ') + : canonical.className, + }; + if (row.orphan) { + orphanClones.push(clone); + if (row.visible && firstOrphanY === undefined) firstOrphanY = position.y; + continue; + } + clones.push(clone); + if (row.depth === 0 && firstTopY === undefined) firstTopY = position.y; + if (row.visible) { + maxVisibleY = maxVisibleY === undefined ? position.y : Math.max(maxVisibleY, position.y); + } + } + + // A laid-out preview row (Add Node panel open) can be the new lowest row on a + // tail append; keep the terminal placeholder below it too. + const previewY = layout.positions.get(PREVIEW_NODE_ID)?.y; + if (previewY !== undefined) { + maxVisibleY = maxVisibleY === undefined ? previewY : Math.max(maxVisibleY, previewY); + } + + // Keep the synthetic start bar one pitch above the earliest rendered + // top-level row. During a head insertion the preview temporarily becomes + // that earliest row; deriving startY only from the first canonical row would + // move the start bar down into the preview's slot and stack the two nodes. + const earliestTopY = Math.min(firstTopY ?? Number.POSITIVE_INFINITY, previewY ?? Infinity); + const startY = (Number.isFinite(earliestTopY) ? earliestTopY : 0) - pitch; + // Below the ENTIRE visible stack (any depth), not just the last top-level row, + // so it never lands inside a trailing container's body and overlap a nested + // leaf's add affordance. + // A non-empty flow's tail is a plus overlay in the gap just below the last row + // (no reserved row); an empty flow shows the dashed "Add step" row as a full row. + const tailIsPlus = firstTopY !== undefined; + const placeholderY = + firstOrphanY !== undefined + ? firstOrphanY - pitch + : (maxVisibleY ?? -pitch) + (tailIsPlus ? barHeight : pitch); + + const startNode: Node = { + id: SEQ_START_ROW_ID, + type: SEQ_START_NODE_TYPE, + position: { x: 0, y: startY }, + width: barWidth, + height: barHeight, + draggable: false, + selectable: callbacks.onAddTrigger !== undefined, + data: { onAddTrigger: callbacks.onAddTrigger } satisfies SequentialStartNodeData, + }; + + const placeholderNode: Node = { + id: SEQ_PLACEHOLDER_ROW_ID, + type: SEQ_PLACEHOLDER_NODE_TYPE, + position: { x: 0, y: placeholderY }, + width: barWidth, + height: tailIsPlus ? rowGap : barHeight, + draggable: false, + selectable: callbacks.onPlaceholderAdd !== undefined, + data: { + onAdd: callbacks.onPlaceholderAdd, + // With real steps above, the tail is a plus overlay in the gap (append); an + // empty flow shows the dashed "Add step" row instead. + variant: tailIsPlus ? 'plus' : 'row', + } satisfies SequentialPlaceholderNodeData, + }; + + return [startNode, ...clones, placeholderNode, ...orphanClones]; +} + +function buildSequentialEdges(projection: SequenceProjection, layout: SequenceLayout): Edge[] { + // A connector into a trailing insert (plus) placeholder must not draw an + // arrowhead: its target is an add affordance, not a real step. + const appendPlaceholderIds = new Set( + projection.rows.filter((row) => row.placeholderKind === 'append').map((row) => row.nodeId) + ); + // A connector touching a row hidden by a collapsed ancestor must not offer an + // insert: you cannot see where the node would land. + const hiddenRowIds = new Set( + projection.rows.filter((row) => !row.visible).map((row) => row.nodeId) + ); + const edges: Edge[] = projection.connectors.map((connector) => { + const isPreviewConnector = + connector.sourceRowId === PREVIEW_NODE_ID || connector.targetRowId === PREVIEW_NODE_ID; + const previewConnectionHandleId = !isPreviewConnector + ? undefined + : connector.sourceRowId === PREVIEW_NODE_ID + ? (connector.slot?.target?.handleId ?? DEFAULT_TARGET_HANDLE_ID) + : (connector.slot?.source?.handleId ?? DEFAULT_SOURCE_HANDLE_ID); + const data: SequentialConnectorData = { + kind: connector.kind, + label: connector.label, + waypoints: layout.connectorWaypoints.get(connector.id) ?? EMPTY_WAYPOINTS, + // The preview connectors retain the semantic slot for canonical handle + // resolution below, but must not offer another insert button while the + // Add Node panel is already open. + slot: + isPreviewConnector || + hiddenRowIds.has(connector.targetRowId) || + hiddenRowIds.has(connector.sourceRowId) + ? undefined + : connector.slot, + preview: isPreviewConnector || undefined, + previewConnectionHandleId, + hideArrowHead: connector.kind === 'goto' || appendPlaceholderIds.has(connector.targetRowId), + }; + return { + id: connector.id, + source: connector.sourceRowId, + target: connector.targetRowId, + sourceHandle: + connector.sourceRowId === PREVIEW_NODE_ID ? 'output' : SEQUENTIAL_BAR_HANDLE_IDS.source, + // Branch/container-entry connectors enter the child's mid-left; every + // other kind drops into its top handle. + targetHandle: + connector.targetRowId === PREVIEW_NODE_ID + ? connector.kind === 'branch-entry' + ? SEQUENTIAL_BAR_HANDLE_IDS.branchTarget + : 'input' + : connector.kind === 'branch-entry' + ? SEQUENTIAL_BAR_HANDLE_IDS.branchTarget + : SEQUENTIAL_BAR_HANDLE_IDS.target, + type: SEQ_CONNECTOR_EDGE_TYPE, + data, + ...(isPreviewConnector + ? { + style: { + opacity: 0.8, + stroke: 'var(--canvas-selection-indicator)', + strokeWidth: 2, + }, + } + : {}), + }; + }); + + // Synthetic joins: start -> first top row, last top row -> placeholder. When + // there are no rows the two synthetic bars connect directly. + const topRows = projection.rows.filter((row) => row.depth === 0 && !row.orphan); + const firstTop = topRows[0]; + const lastVisibleTop = [...topRows].reverse().find((row) => row.visible); + + const syntheticData = (hideArrowHead = true): SequentialConnectorData => ({ + kind: 'step', + waypoints: EMPTY_WAYPOINTS, + hideArrowHead, + }); + + const syntheticPreviewData = (hideArrowHead = true): SequentialConnectorData => ({ + ...syntheticData(hideArrowHead), + preview: true, + ignorePreviewConnection: true, + }); + + const previewIsFirst = firstTop?.nodeId === PREVIEW_NODE_ID; + const headSlot: InsertionSlot | undefined = + firstTop && !previewIsFirst + ? { id: `slot:head:${firstTop.nodeId}`, target: { nodeId: firstTop.nodeId } } + : undefined; + edges.push({ + id: SEQ_START_EDGE_ID, + source: SEQ_START_ROW_ID, + target: firstTop?.nodeId ?? SEQ_PLACEHOLDER_ROW_ID, + sourceHandle: SEQUENTIAL_BAR_HANDLE_IDS.source, + targetHandle: previewIsFirst ? 'input' : SEQUENTIAL_BAR_HANDLE_IDS.target, + type: SEQ_CONNECTOR_EDGE_TYPE, + data: previewIsFirst + ? syntheticPreviewData(false) + : { ...syntheticData(firstTop === undefined), slot: headSlot }, + ...(previewIsFirst + ? { + style: { + opacity: 0.8, + stroke: 'var(--canvas-selection-indicator)', + strokeWidth: 2, + }, + } + : {}), + }); + + if (lastVisibleTop) { + const previewIsLast = lastVisibleTop.nodeId === PREVIEW_NODE_ID; + edges.push({ + id: SEQ_PLACEHOLDER_EDGE_ID, + source: lastVisibleTop.nodeId, + target: SEQ_PLACEHOLDER_ROW_ID, + sourceHandle: previewIsLast ? 'output' : SEQUENTIAL_BAR_HANDLE_IDS.source, + targetHandle: SEQUENTIAL_BAR_HANDLE_IDS.target, + type: SEQ_CONNECTOR_EDGE_TYPE, + data: previewIsLast ? syntheticPreviewData() : syntheticData(), + ...(previewIsLast + ? { + style: { + opacity: 0.8, + stroke: 'var(--canvas-selection-indicator)', + strokeWidth: 2, + }, + } + : {}), + }); + } + + return edges; +} + +/** + * Stamps each numbered row's clone with `node.ariaLabel` (xyflow renders this + * as `aria-label` on the node's DOM wrapper): "Step {n} of {total}: {label}" + * (D8). `total` counts every numbered row regardless of visibility, so it + * never shifts when a container collapses (D7's stable-numbering guarantee + * extends to the announced total). The label is resolved through the node + * type registry the same way `BaseNode` resolves its printed label + * (`resolveDisplay(manifest.display, {display: node.data.display})`), so the + * announced name matches what's on the bar; a registry-less host (or an + * unrecognized type) still gets a readable fallback ("Unknown Node") instead + * of a blank label. Synthetic rows (start bar, placeholder) are left + * untouched -- they already carry their own visible, non-templated text. + * + * This only runs inside the {@link useSequentialGraph} hook (which has React + * context for the registry + lingui), not {@link deriveSequentialGraph} (the + * pure function exercised directly by tests without mounting either provider). + */ +function stampStepAriaLabels( + nodes: Node[], + projection: SequenceProjection, + registry: NodeTypeRegistry | null, + translate: TranslateFn +): Node[] { + const stepNumberByRowId = new Map(); + let total = 0; + for (const row of projection.rows) { + if (row.stepNumber === undefined) continue; + stepNumberByRowId.set(row.nodeId, row.stepNumber); + total += 1; + } + if (total === 0) return nodes; + + return nodes.map((node) => { + const stepNumber = stepNumberByRowId.get(node.id); + if (stepNumber === undefined) return node; + + const manifest = registry?.getManifest(node.type ?? ''); + const display = resolveDisplay(manifest?.display, { + display: (node.data as { display?: InstanceDisplayConfig } | undefined)?.display, + }); + + const ariaLabel = translate({ + id: 'sequential-canvas.step.aria-label', + message: 'Step {stepNumber} of {total}: {label}', + values: { stepNumber, total, label: display.label }, + }); + + return { ...node, ariaLabel }; + }); +} + +function shallowRecordEqual( + previous: Record, + next: Record +): boolean { + const previousKeys = Object.keys(previous); + const nextKeys = Object.keys(next); + if (previousKeys.length !== nextKeys.length) return false; + for (const key of previousKeys) { + if (!Object.hasOwn(next, key)) return false; + if (!Object.is(previous[key], next[key])) return false; + } + return true; +} + +function shallowNodeEqualExceptPositionAndData(previous: Node, next: Node): boolean { + const previousRecord = previous as unknown as Record; + const nextRecord = next as unknown as Record; + const previousKeys = Object.keys(previousRecord); + const nextKeys = Object.keys(nextRecord); + if (previousKeys.length !== nextKeys.length) return false; + for (const key of previousKeys) { + if (!Object.hasOwn(nextRecord, key)) return false; + if (key === 'position' || key === 'data') continue; + if (!Object.is(previousRecord[key], nextRecord[key])) return false; + } + return true; +} + +/** + * Reuses the prior object for every derived row whose rendered inputs did not + * change. A canonical selection update replaces the canonical nodes array, but + * normally changes only the previously-selected and newly-selected nodes. + * Keeping every other derived node reference stable lets xyflow preserve its + * internal node entries instead of reconciling the entire graph. + */ +function reuseUnchangedSequentialNodes(previous: Node[], next: Node[]): Node[] { + const previousById = new Map(previous.map((node) => [node.id, node])); + return next.map((node) => { + const prior = previousById.get(node.id); + if (!prior) return node; + + const priorPosition = prior.position; + const nextPosition = node.position; + const priorData = prior.data as Record; + const nextData = node.data as Record; + const hasGeneratedData = + node.type === SEQ_START_NODE_TYPE || node.type === SEQ_PLACEHOLDER_NODE_TYPE; + const dataEqual = + Object.is(prior.data, node.data) || + (hasGeneratedData && shallowRecordEqual(priorData, nextData)); + + return priorPosition.x === nextPosition.x && + priorPosition.y === nextPosition.y && + dataEqual && + shallowNodeEqualExceptPositionAndData(prior, node) + ? prior + : node; + }); +} + +/** + * React hook wrapping {@link deriveSequentialGraph} with the D12 memoization: + * projection + layout recompute only when the STRUCTURAL fingerprint changes, so + * a rename keystroke (data-only) reuses them. The clone nodes recompute when the + * canonical `nodes` reference changes (to reflect new `data`), always reading the + * fingerprint-memoized layout positions; the connector edges are reference-stable + * across data-only changes (they depend only on projection + layout), satisfying + * the WS3 edge-data stability contract. + */ +export function useSequentialGraph( + args: UseSequentialGraphArgs +): SequentialGraph { + const { + nodes, + edges, + view, + collapsedStepIds, + onAddTrigger, + onPlaceholderAdd, + isSequenceEdge, + isSequenceNode, + isStartNode, + isContainerNode, + resolveBranchLabel, + getBranchHandles, + onLaneAdd, + insertSlot, + layoutOptions, + } = args; + + const { _ } = useSafeLingui(); + const registry = useOptionalNodeTypeRegistry(); + const previousSeqNodesRef = useRef([]); + + const collapsed = collapsedStepIds ?? EMPTY_COLLAPSED; + const fingerprint = useMemo( + () => sequenceFingerprint(nodes, edges, collapsed), + [nodes, edges, collapsed] + ); + // These fingerprints only guard the sequential projection memo below, which + // short-circuits to null in flow view; skip the full-graph passes there. + const inSequentialView = view === 'sequential'; + const labelFingerprint = useMemo( + () => + inSequentialView + ? edges + .map((edge) => { + const handleId = edge.sourceHandle ?? DEFAULT_SOURCE_HANDLE_ID; + const resolved = resolveBranchLabel?.(edge.source, handleId); + const fallback = (edge.data as { label?: string | null } | undefined)?.label; + // NUL separates id from label so neither can forge a collision. + return `${edge.id}${resolved ?? fallback ?? ''}`; + }) + .sort() + .join('|') + : '', + [inSequentialView, edges, resolveBranchLabel] + ); + const edgeInclusionFingerprint = useMemo( + () => (inSequentialView ? predicateFingerprint(edges, isSequenceEdge, 1) : ''), + [inSequentialView, edges, isSequenceEdge] + ); + const nodeInclusionFingerprint = useMemo( + () => (inSequentialView ? predicateFingerprint(nodes, isSequenceNode, 1) : ''), + [inSequentialView, nodes, isSequenceNode] + ); + const startNodeFingerprint = useMemo( + () => (inSequentialView ? predicateFingerprint(nodes, isStartNode, 0) : ''), + [inSequentialView, nodes, isStartNode] + ); + const containerNodeFingerprint = useMemo( + () => (inSequentialView ? predicateFingerprint(nodes, isContainerNode, 0) : ''), + [inSequentialView, nodes, isContainerNode] + ); + // Which nodes are parents (branch-lane handles + their labels), so the + // projection recomputes when a node gains/loses lanes or a lane relabels. + const branchHandlesFingerprint = useMemo( + () => + inSequentialView && getBranchHandles + ? nodes + .map( + (node) => + `${node.id}:${getBranchHandles(node) + .map((h) => `${h.id}=${h.label}`) + .join(',')}` + ) + .join('|') + : '', + [inSequentialView, nodes, getBranchHandles] + ); + + // Recompute the projection only on a structural change (the fingerprint, D12) + // or when the projection predicates change identity; a data-only edit (e.g. a + // rename keystroke) produces the same fingerprint string and reuses the cached + // projection. Keying on `fingerprint` instead of the raw nodes/edges arrays is + // the whole point, so the exhaustive-deps rule is intentionally overridden. + // biome-ignore lint/correctness/useExhaustiveDependencies: keyed on the structural fingerprint by design (D12). + const projection = useMemo(() => { + if (view !== 'sequential') return null; + const sequenceNodes = isSequenceNode ? nodes.filter(isSequenceNode) : nodes; + const sequenceNodeIds = new Set(sequenceNodes.map((node) => node.id)); + const sequenceEdges = edges.filter( + (edge) => sequenceNodeIds.has(edge.source) && sequenceNodeIds.has(edge.target) + ); + return projectSequence(sequenceNodes, sequenceEdges, { + collapsedStepIds: collapsed, + isSequenceEdge: isSequenceEdge as ((edge: Edge) => boolean) | undefined, + isStartNode: isStartNode as ((node: Node) => boolean) | undefined, + isContainerNode: isContainerNode as ((node: Node) => boolean) | undefined, + resolveBranchLabel, + getBranchHandles: getBranchHandles as + | ((node: Node) => { id: string; label: string }[]) + | undefined, + }); + }, [ + fingerprint, + labelFingerprint, + edgeInclusionFingerprint, + nodeInclusionFingerprint, + startNodeFingerprint, + containerNodeFingerprint, + branchHandlesFingerprint, + view, + ]); + + // Layout is a cheap pure geometry pass, kept separate from the projection memo + // so it can re-run for the insert-preview gap WITHOUT re-projecting: while a + // slot's Add Node panel is open, lay out a preview-augmented projection so the + // gap opens one row, the preview bar lands in its slot, and every connector + // re-routes from the shifted positions. + const renderedProjection = useMemo(() => { + if (!projection) return null; + return insertSlot ? projectionWithPreviewRow(projection, insertSlot) : projection; + }, [projection, insertSlot]); + + const layout = useMemo( + () => (renderedProjection ? layoutSequence(renderedProjection, layoutOptions) : null), + [renderedProjection, layoutOptions] + ); + + const seqNodes = useMemo(() => { + if (!projection || !layout) return null; + const nodesById = new Map(nodes.map((node) => [node.id, node] as const)); + const built = buildSequentialNodes( + projection, + layout, + nodesById, + { + onAddTrigger, + onPlaceholderAdd, + onLaneAdd, + }, + layoutOptions + ); + const stamped = stampStepAriaLabels(built, projection, registry, _); + const stable = reuseUnchangedSequentialNodes(previousSeqNodesRef.current, stamped); + previousSeqNodesRef.current = stable; + return stable; + }, [ + projection, + layout, + nodes, + onAddTrigger, + onPlaceholderAdd, + onLaneAdd, + layoutOptions, + registry, + _, + ]); + + const seqEdges = useMemo(() => { + if (!renderedProjection || !layout) return null; + return buildSequentialEdges(renderedProjection, layout); + }, [renderedProjection, layout]); + + if (view !== 'sequential' || !projection || !seqNodes || !seqEdges) { + return { nodes, edges, projection: null, layout: null }; + } + + return { + nodes: seqNodes as unknown as N[], + edges: seqEdges as unknown as E[], + projection, + layout, + }; +} diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/useSequentialKeyboard.test.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/useSequentialKeyboard.test.ts new file mode 100644 index 000000000..f5b8397fd --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/useSequentialKeyboard.test.ts @@ -0,0 +1,387 @@ +import { renderHook } from '@testing-library/react'; +import type { KeyboardEvent as ReactKeyboardEvent } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { + getAdjacentRowId, + isTypingTarget, + type SequentialKeyboardRow, + toggleCollapsedStepIds, + useSequentialKeyboard, +} from './useSequentialKeyboard'; + +function makeRow(overrides: Partial = {}): SequentialKeyboardRow { + return { nodeId: 'a', collapsible: false, collapsed: false, ...overrides }; +} + +function makeKeyEvent( + key: string, + target: EventTarget = document.body, + altKey = false +): ReactKeyboardEvent { + return { + key, + target, + altKey, + preventDefault: vi.fn(), + } as unknown as ReactKeyboardEvent; +} + +describe('isTypingTarget', () => { + it('is false for a plain div', () => { + expect(isTypingTarget(document.createElement('div'))).toBe(false); + }); + + it.each(['INPUT', 'TEXTAREA', 'SELECT'])('is true for a %s element', (tag) => { + expect(isTypingTarget(document.createElement(tag))).toBe(true); + }); + + it('is true for a contenteditable element', () => { + const div = document.createElement('div'); + Object.defineProperty(div, 'isContentEditable', { value: true }); + expect(isTypingTarget(div)).toBe(true); + }); + + it('is false for null', () => { + expect(isTypingTarget(null)).toBe(false); + }); +}); + +describe('getAdjacentRowId', () => { + const rows = [{ nodeId: 'a' }, { nodeId: 'b' }, { nodeId: 'c' }]; + + it('returns the next row after the selected one', () => { + expect(getAdjacentRowId(rows, 'a', 'next')).toBe('b'); + }); + + it('returns the previous row before the selected one', () => { + expect(getAdjacentRowId(rows, 'c', 'prev')).toBe('b'); + }); + + it('returns undefined past the last row (no wraparound)', () => { + expect(getAdjacentRowId(rows, 'c', 'next')).toBeUndefined(); + }); + + it('returns undefined before the first row (no wraparound)', () => { + expect(getAdjacentRowId(rows, 'a', 'prev')).toBeUndefined(); + }); + + it('jumps to the first row on next when nothing is selected', () => { + expect(getAdjacentRowId(rows, undefined, 'next')).toBe('a'); + }); + + it('jumps to the last row on prev when nothing is selected', () => { + expect(getAdjacentRowId(rows, undefined, 'prev')).toBe('c'); + }); + + it('returns undefined for an empty row list', () => { + expect(getAdjacentRowId([], undefined, 'next')).toBeUndefined(); + }); +}); + +describe('toggleCollapsedStepIds', () => { + it('adds the id when collapsing', () => { + expect(toggleCollapsedStepIds(new Set(['x']), 'a', true).sort()).toEqual(['a', 'x']); + }); + + it('removes the id when expanding', () => { + expect(toggleCollapsedStepIds(new Set(['a', 'x']), 'a', false)).toEqual(['x']); + }); +}); + +describe('useSequentialKeyboard', () => { + it('ignores keys entirely while the event target is a typing surface', () => { + const onSelectNode = vi.fn(); + const { result } = renderHook(() => + useSequentialKeyboard({ + rows: [makeRow({ nodeId: 'a' }), makeRow({ nodeId: 'b' })], + selectedNodeId: 'a', + collapsedStepIds: new Set(), + onSelectNode, + }) + ); + + result.current.onKeyDown(makeKeyEvent('ArrowDown', document.createElement('input'))); + expect(onSelectNode).not.toHaveBeenCalled(); + }); + + it('moves selection to the next row on ArrowDown and prevents default', () => { + const onSelectNode = vi.fn(); + const { result } = renderHook(() => + useSequentialKeyboard({ + rows: [makeRow({ nodeId: 'a' }), makeRow({ nodeId: 'b' })], + selectedNodeId: 'a', + collapsedStepIds: new Set(), + onSelectNode, + }) + ); + + const event = makeKeyEvent('ArrowDown'); + result.current.onKeyDown(event); + expect(onSelectNode).toHaveBeenCalledWith('b'); + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it('moves selection to the previous row on ArrowUp', () => { + const onSelectNode = vi.fn(); + const { result } = renderHook(() => + useSequentialKeyboard({ + rows: [makeRow({ nodeId: 'a' }), makeRow({ nodeId: 'b' })], + selectedNodeId: 'b', + collapsedStepIds: new Set(), + onSelectNode, + }) + ); + + result.current.onKeyDown(makeKeyEvent('ArrowUp')); + expect(onSelectNode).toHaveBeenCalledWith('a'); + }); + + it('does not call onSelectNode at a boundary with no adjacent row', () => { + const onSelectNode = vi.fn(); + const { result } = renderHook(() => + useSequentialKeyboard({ + rows: [makeRow({ nodeId: 'a' })], + selectedNodeId: 'a', + collapsedStepIds: new Set(), + onSelectNode, + }) + ); + + result.current.onKeyDown(makeKeyEvent('ArrowDown')); + expect(onSelectNode).not.toHaveBeenCalled(); + }); + + it('collapses the selected row on ArrowLeft when it is collapsible and expanded', () => { + const onCollapsedStepIdsChange = vi.fn(); + const { result } = renderHook(() => + useSequentialKeyboard({ + rows: [makeRow({ nodeId: 'a', collapsible: true, collapsed: false })], + selectedNodeId: 'a', + collapsedStepIds: new Set(), + onSelectNode: vi.fn(), + onCollapsedStepIdsChange, + }) + ); + + result.current.onKeyDown(makeKeyEvent('ArrowLeft')); + expect(onCollapsedStepIdsChange).toHaveBeenCalledWith(['a']); + }); + + it('does nothing on ArrowLeft when the selected row is not collapsible', () => { + const onCollapsedStepIdsChange = vi.fn(); + const { result } = renderHook(() => + useSequentialKeyboard({ + rows: [makeRow({ nodeId: 'a', collapsible: false })], + selectedNodeId: 'a', + collapsedStepIds: new Set(), + onSelectNode: vi.fn(), + onCollapsedStepIdsChange, + }) + ); + + result.current.onKeyDown(makeKeyEvent('ArrowLeft')); + expect(onCollapsedStepIdsChange).not.toHaveBeenCalled(); + }); + + it('does nothing on ArrowLeft when the selected row is already collapsed', () => { + const onCollapsedStepIdsChange = vi.fn(); + const { result } = renderHook(() => + useSequentialKeyboard({ + rows: [makeRow({ nodeId: 'a', collapsible: true, collapsed: true })], + selectedNodeId: 'a', + collapsedStepIds: new Set(['a']), + onSelectNode: vi.fn(), + onCollapsedStepIdsChange, + }) + ); + + result.current.onKeyDown(makeKeyEvent('ArrowLeft')); + expect(onCollapsedStepIdsChange).not.toHaveBeenCalled(); + }); + + it('expands the selected row on ArrowRight when it is collapsible and collapsed', () => { + const onCollapsedStepIdsChange = vi.fn(); + const { result } = renderHook(() => + useSequentialKeyboard({ + rows: [makeRow({ nodeId: 'a', collapsible: true, collapsed: true })], + selectedNodeId: 'a', + collapsedStepIds: new Set(['a']), + onSelectNode: vi.fn(), + onCollapsedStepIdsChange, + }) + ); + + result.current.onKeyDown(makeKeyEvent('ArrowRight')); + expect(onCollapsedStepIdsChange).toHaveBeenCalledWith([]); + }); + + it('fires onPrimaryAction with the selected node id on Enter when supplied', () => { + const onPrimaryAction = vi.fn(); + const { result } = renderHook(() => + useSequentialKeyboard({ + rows: [makeRow({ nodeId: 'a' })], + selectedNodeId: 'a', + collapsedStepIds: new Set(), + onSelectNode: vi.fn(), + onPrimaryAction, + }) + ); + + result.current.onKeyDown(makeKeyEvent('Enter')); + expect(onPrimaryAction).toHaveBeenCalledWith('a'); + }); + + it('is a no-op on Enter when no onPrimaryAction is supplied', () => { + const { result } = renderHook(() => + useSequentialKeyboard({ + rows: [makeRow({ nodeId: 'a' })], + selectedNodeId: 'a', + collapsedStepIds: new Set(), + onSelectNode: vi.fn(), + }) + ); + + const event = makeKeyEvent('Enter'); + expect(() => result.current.onKeyDown(event)).not.toThrow(); + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + + it.each([ + 'Delete', + 'Backspace', + ])('semantically deletes the selected row on %s in design mode', (key) => { + const onDeleteNode = vi.fn(); + const { result } = renderHook(() => + useSequentialKeyboard({ + rows: [makeRow({ nodeId: 'a' })], + selectedNodeId: 'a', + collapsedStepIds: new Set(), + onSelectNode: vi.fn(), + onDeleteNode, + isDesignMode: true, + }) + ); + + const event = makeKeyEvent(key); + result.current.onKeyDown(event); + expect(event.preventDefault).toHaveBeenCalled(); + expect(onDeleteNode).toHaveBeenCalledWith('a'); + }); + + it('does not delete outside design mode', () => { + const onDeleteNode = vi.fn(); + const { result } = renderHook(() => + useSequentialKeyboard({ + rows: [makeRow({ nodeId: 'a' })], + selectedNodeId: 'a', + collapsedStepIds: new Set(), + onSelectNode: vi.fn(), + onDeleteNode, + isDesignMode: false, + }) + ); + + const event = makeKeyEvent('Delete'); + result.current.onKeyDown(event); + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(onDeleteNode).not.toHaveBeenCalled(); + }); + + describe('Alt+Arrow tree move', () => { + it.each([ + ['ArrowUp', 'up'], + ['ArrowDown', 'down'], + ['ArrowLeft', 'outdent'], + ['ArrowRight', 'indent'], + ] as const)('calls onMoveNode with direction %s -> %s in design mode', (key, direction) => { + const onMoveNode = vi.fn(); + const { result } = renderHook(() => + useSequentialKeyboard({ + rows: [makeRow({ nodeId: 'a' })], + selectedNodeId: 'a', + collapsedStepIds: new Set(), + onSelectNode: vi.fn(), + onMoveNode, + isDesignMode: true, + }) + ); + + const event = makeKeyEvent(key, document.body, true); + result.current.onKeyDown(event); + expect(onMoveNode).toHaveBeenCalledWith('a', direction); + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it('does not call onMoveNode outside design mode, and does not fall through to plain navigation', () => { + const onMoveNode = vi.fn(); + const onSelectNode = vi.fn(); + const { result } = renderHook(() => + useSequentialKeyboard({ + rows: [makeRow({ nodeId: 'a' }), makeRow({ nodeId: 'b' })], + selectedNodeId: 'a', + collapsedStepIds: new Set(), + onSelectNode, + onMoveNode, + isDesignMode: false, + }) + ); + + result.current.onKeyDown(makeKeyEvent('ArrowDown', document.body, true)); + expect(onMoveNode).not.toHaveBeenCalled(); + expect(onSelectNode).not.toHaveBeenCalled(); + }); + + it('does not call onMoveNode with no row selected', () => { + const onMoveNode = vi.fn(); + const { result } = renderHook(() => + useSequentialKeyboard({ + rows: [makeRow({ nodeId: 'a' })], + selectedNodeId: undefined, + collapsedStepIds: new Set(), + onSelectNode: vi.fn(), + onMoveNode, + isDesignMode: true, + }) + ); + + result.current.onKeyDown(makeKeyEvent('ArrowUp', document.body, true)); + expect(onMoveNode).not.toHaveBeenCalled(); + }); + + it('plain (non-Alt) ArrowUp/Down/Left/Right still navigate/collapse exactly as before', () => { + const onSelectNode = vi.fn(); + const onMoveNode = vi.fn(); + const { result } = renderHook(() => + useSequentialKeyboard({ + rows: [makeRow({ nodeId: 'a' }), makeRow({ nodeId: 'b' })], + selectedNodeId: 'a', + collapsedStepIds: new Set(), + onSelectNode, + onMoveNode, + isDesignMode: true, + }) + ); + + result.current.onKeyDown(makeKeyEvent('ArrowDown')); + expect(onSelectNode).toHaveBeenCalledWith('b'); + expect(onMoveNode).not.toHaveBeenCalled(); + }); + + it('ignores Alt+Arrow entirely while the event target is a typing surface', () => { + const onMoveNode = vi.fn(); + const { result } = renderHook(() => + useSequentialKeyboard({ + rows: [makeRow({ nodeId: 'a' })], + selectedNodeId: 'a', + collapsedStepIds: new Set(), + onSelectNode: vi.fn(), + onMoveNode, + isDesignMode: true, + }) + ); + + result.current.onKeyDown(makeKeyEvent('ArrowUp', document.createElement('textarea'), true)); + expect(onMoveNode).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/useSequentialKeyboard.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/useSequentialKeyboard.ts new file mode 100644 index 000000000..c8ebc4dc7 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/useSequentialKeyboard.ts @@ -0,0 +1,236 @@ +import type { KeyboardEvent as ReactKeyboardEvent } from 'react'; +import { useCallback } from 'react'; +import type { SequenceRow } from '../../utils/sequential/sequential.types'; + +/** The subset of a row's fields the keyboard hook needs to navigate/collapse it. */ +export interface SequentialKeyboardRow + extends Pick {} + +export interface UseSequentialKeyboardArgs { + /** + * Visible, numbered rows in row order -- the same array `SequentialGutter` + * renders from (see `SequentialCanvas.tsx`), so ArrowUp/Down walk the exact + * order screen readers traverse the DOM in (D8: `onlyRenderVisibleElements` + * off means DOM order === row order). + */ + rows: readonly SequentialKeyboardRow[]; + /** The single selected row's node id, if any (single-select contract). */ + selectedNodeId?: string; + collapsedStepIds: ReadonlySet; + /** + * Moves single selection to a row's node id. The canvas assembly wires this + * through the same `onNodesChange` path a mouse click already uses (a + * `select` NodeChange per touched node), so keyboard and pointer selection + * are indistinguishable to the consumer's canonical state. + */ + onSelectNode: (nodeId: string) => void; + onCollapsedStepIdsChange?: (ids: string[]) => void; + /** + * Enter's primary action, if the host supplies one. `SequentialCanvas` does + * not wire this today: a bar's toolbar actions are resolved per-node deep + * inside `BaseNode`/`BaseNodeBar` via `resolveToolbar` + that node's own + * manifest/status context (see `BaseNodeBar.tsx`'s `menuItems` memo), and are + * never surfaced centrally to the canvas assembly. Reusing "the first + * resolved action" here would require either lifting toolbar resolution out + * of BaseNode (a BaseNode-owning change) or adding an event-bus side channel + * the bars call into on mount -- both bigger than this component's scope. + * Enter is therefore a documented no-op until a host supplies `onPrimaryAction`. + */ + onPrimaryAction?: (nodeId: string) => void; + /** + * Alt+Arrow explorer-like tree move: Alt+ArrowUp = move + * up, Alt+ArrowDown = move down, Alt+ArrowLeft = outdent, Alt+ArrowRight = + * indent, applied to the selected row. Shares the SAME commit path the + * kebab items use (see `useSequentialMoveMenuItems` / `commitMove` in + * `SequentialCanvas.tsx`), so keyboard and kebab can never disagree. + * Gated on `isDesignMode` (a topology mutation, D4) in addition to the + * typing-target guard every other key already has; a plain Arrow key + * without Alt is untouched by this and keeps navigating/collapsing exactly + * as before. + */ + onMoveNode?: (nodeId: string, direction: 'up' | 'down' | 'indent' | 'outdent') => void; + /** Semantically removes the selected step and heals its graph seam. */ + onDeleteNode?: (nodeId: string) => void; + isDesignMode?: boolean; +} + +export interface UseSequentialKeyboardResult { + /** Attach to a wrapper div around `BaseCanvas` (see `SequentialCanvas.tsx`). */ + onKeyDown: (event: ReactKeyboardEvent) => void; +} + +const TYPING_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT']); + +/** + * True when the event's target is an editable surface: an inline-rename + * `