diff --git a/packages/apollo-react/src/canvas/README.md b/packages/apollo-react/src/canvas/README.md index 7395e635e..97f4370d5 100644 --- a/packages/apollo-react/src/canvas/README.md +++ b/packages/apollo-react/src/canvas/README.md @@ -195,3 +195,90 @@ 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 with a `level` and an `editable` flag, and the guidance is three-way: + +1. **Editable.** `level: 'exact'`, and also `level: 'degraded'` when the only issues are `multiple-roots` or `orphan`. These graphs render correctly and are recoverable by ordinary editing, so keep them fully editable. Locking the view as soon as a user has two disconnected steps would lock them out of the only view that flags the problem. +2. **Read-only.** A report containing a `cycle` or `unstructured-merge` issue. The slot-based mutation operations cannot express a safe splice across a `goto` connector, because a `goto` carries no insertion slot. +3. **Not supported.** `level: 'unsupported'`, which means malformed containment (a duplicate node id, a missing parent, or a parent cycle). + +The component does not enforce any of this. The host reads `report.editable` and chooses the `mode` it passes to `SequentialCanvas`, which is what the example below does. Presentation-only nodes and edges can be excluded from the projection and preserved in the canonical graph. + +### Availability + +Everything ships from `@uipath/apollo-react/canvas`, the same entry point as the rest of the canvas. The component surface (`SequentialCanvas`, `SequentialCanvasProps`, `prepareCanvasViewTransition`, `ViewSwitcher`, `useCanvasViewMode`, `SequentialViewProvider`) and the pure projection engine (`analyzeSequentialCompatibility`, `projectSequence`, `layoutSequence`, the report types) are both re-exported from it, and both follow the package's normal semver. + +One caveat to plan around: the sequential view is pre-GA. Its documented v1 limitations (see "Degraded graphs" below, plus bare branch owners that cannot be moved and loop-close edges that are not relocated) mean the props are still likely to change, so pin the package version if you adopt it early and read the release notes before upgrading. If you only need to reason about a graph rather than render it, depend on the projection engine and skip the component entirely: it is pure, has no xyflow or registry dependency, and is the more settled half of the two. + +### 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 [report, setReport] = useState(undefined); + + const changeView = (nextView) => { + const transition = prepareCanvasViewTransition(nextView, nodes, edges); + setNodes(transition.nodes); + // Only populated when entering sequential. Recompute it whenever the graph + // changes while sequential is on screen, or the advice below goes stale. + setReport(transition.sequentialCompatibility); + setView(nextView); + }; + + // The component never locks itself. The host decides, from `editable`. + const mode = view === 'sequential' && report && !report.editable ? 'readonly' : 'design'; + + 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 pan save/restore and a live compatibility banner, see `SequentialCanvasStoryHarness` and the `Wireframe` story. Note that pan is saved per view but zoom is not: it carries across the toggle from the view being left, so switching views reframes the graph without changing how far the user is zoomed in. + +### Degraded graphs + +Not every graph is a clean, structured sequence. The projection degrades gracefully instead of failing: + +| Shape | Issue code | Rendering | Still editable? | +|---|---|---|---| +| Multiple roots or disconnected components | `multiple-roots` | Stacked lanes ordered by flow-view y | Yes | +| Orphans (no sequence edges at all) | `orphan` | A de-emphasized trailing section after the terminal placeholder | Yes | +| Unstructured merge (a node with more than one incoming edge) | `unstructured-merge` | Placed under its first incomer; the extra incoming edge draws a dashed goto connector | No | +| Cycles other than a loop's `loopBack` handle | `cycle` | A dashed, arrowless goto connector closes the cycle | No | + +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, and rendering is independent of editability. A read-only degraded graph is still worth showing, since seeing the dashed goto connector is how a user finds the structure to fix. + +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, + // The bar's layout-owned width, read through the useStore subscription + // below rather than the measured `width` prop. See BaseNodeBarNode. + width: 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, + // Run the real selectors against a minimal store rather than returning a fixed + // value: the bar subtree has two `useStore` callers with different shapes. + // `useBaseNodePresentation` reads `connection.inProgress` (kept false, as the + // previous `() => false` stub gave it), and BaseNodeBarNode subscribes to its + // own node's declared width through `nodeLookup`. + useStore: (selector: (state: unknown) => unknown) => + selector({ + connection: { inProgress: false }, + nodeLookup: { get: () => mockNodeState.current }, + }), + 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, width: 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'); + }); + + // The sequential layout stamps an explicit width on every derived bar, and + // that declared value is what the bar must paint. XYFlow's `width` prop is the + // MEASURED width, which lags a barWidth change by a render and still reports + // the flow-view card width right after a flow->sequential toggle. + it('sizes the bar from the declared node width, not the measured width prop', () => { + mockNodeState.current = { ...mockNodeState.current, width: 560 }; + render(); + expect(screen.getByTestId('sequential-bar').style.getPropertyValue('--node-w')).toBe('560px'); + }); + + // A consumer whose barWidth happens to equal a card default must still get the + // width it asked for. The previous sentinel check discarded exactly these two + // values and silently substituted 896. + it.each([96, 288])('honors a declared width of %ipx that matches a card default', (width) => { + mockNodeState.current = { ...mockNodeState.current, width }; + render(); + expect(screen.getByTestId('sequential-bar').style.getPropertyValue('--node-w')).toBe( + `${width}px` + ); + }); + + // The standalone path: no sequential derivation, so no declared width. The + // isolated card-vs-bar story reaches the renderer this way. + it('falls back to the default bar width when the node declares none', () => { + render(); + expect(screen.getByTestId('sequential-bar').style.getPropertyValue('--node-w')).toBe('896px'); + }); + + 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..93b1e91a9 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/BaseNode/BaseNodeBarNode.tsx @@ -0,0 +1,143 @@ +import type { Node, NodeProps } from '@uipath/apollo-react/canvas/xyflow/react'; +import { + useReactFlow, + useStore, + useUpdateNodeInternals, +} from '@uipath/apollo-react/canvas/xyflow/react'; +import { memo, useEffect, useMemo } from 'react'; +import { 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, + 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; + // Bar width comes from the node's DECLARED width, not from the `width` prop. + // XYFlow hands components `measured.width ?? node.width ?? initialWidth ?? 0`, + // so the prop reports whatever the DOM last measured. That is wrong here in two + // ways: right after a flow->sequential toggle the clone still carries the + // `measured` card width it had in flow view (the derivation spreads the + // canonical node), and an unmeasured node reports `0`. The declared width is the + // honest signal: `buildSequentialNodes` stamps an explicit `width` (the layout's + // `barWidth`) on every derived bar, and XYFlow applies that same value as the + // node wrapper's inline width, so sizing the bar to it is correct by + // construction. + // + // This is a `useStore` SUBSCRIPTION rather than an imperative `getNode` read + // because the two values move independently: when the declared width changes but + // `measured.width` has not caught up yet (the "Interactive Sequence" story's + // barWidth slider does exactly this on every drag), no prop changes, so + // `areNodePropsEqualIgnoringPosition` would block the re-render and the bar would + // paint the stale width inside a wrapper XYFlow has already resized. Subscribing + // makes the declared width itself the trigger. Same pattern and same reasoning as + // `SequentialConnectorEdge`, which subscribes to its target's declared-then- + // measured height for the identical one-render slider lag. + // + // Falling back to `SEQ_BAR_WIDTH` covers the only path with no declared width: + // this renderer used outside the sequential derivation, e.g. the isolated + // `nodes/BarVariant.stories.tsx` card-vs-bar comparison, where the wrapper + // auto-sizes to the bar instead of the bar to the wrapper. + const declaredWidth = useStore((state) => state.nodeLookup.get(id)?.width); + const barWidth = declaredWidth ?? 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 bf0af41da..9fc837bde 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 @@ -15,15 +15,40 @@ export type EdgeLabelProps = { // Falls back to core color tokens when canvas-specific tokens are unavailable, // such as in a host that doesn't import canvas/styles/variables.css. -const EDGE_LABEL_BASE_CLASS = - 'react-flow__edge-label nodrag nopan absolute top-0 left-0 max-w-48 overflow-hidden text-ellipsis ' + - 'whitespace-nowrap pointer-events-auto cursor-default ' + +const EDGE_LABEL_VISUAL_CLASS = + 'react-flow__edge-label nodrag nopan whitespace-nowrap ' + 'px-2 py-1 rounded text-xs font-medium border shadow-[0_1px_3px_0_rgba(0,0,0,0.1)] ' + 'text-[var(--canvas-foreground,var(--color-foreground))] ' + 'bg-[var(--canvas-background,var(--color-background))]'; +const EDGE_LABEL_BASE_CLASS = + `${EDGE_LABEL_VISUAL_CLASS} absolute top-0 left-0 max-w-48 overflow-hidden text-ellipsis ` + + 'pointer-events-auto cursor-default'; + export const EDGE_LABEL_DEFAULT_BORDER_COLOR = 'var(--canvas-border,var(--color-border))'; +export type EdgeLabelContentProps = Pick & { + selected?: boolean; +}; + +/** + * Shared visual treatment for callers that own their own positioning, such as + * a branch header anchored to a row rather than to a point on the edge path. + */ +export function EdgeLabelContent({ text, selected }: EdgeLabelContentProps) { + return ( +
+ {text} +
+ ); +} + /** * Portals into xyflow's `edgelabel-renderer` div, which is a DOM sibling that * always paints after every edge's own ``. Rendering the label as a plain 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.branchHandles.test.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCanvas.branchHandles.test.ts new file mode 100644 index 000000000..cbbfb29f8 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCanvas.branchHandles.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from 'vitest'; +import { resolveBranchHandleIds } from './SequentialCanvas'; + +/** + * Unit coverage for `resolveBranchHandleIds`, the helper that decides which ONE + * of a node's visible non-artifact source handles carries the forward flow (the + * spine) and which are branch lanes. + * + * This is the highest-consequence decision in the projection. Getting it wrong + * does not produce a cosmetic glitch: a node whose spine is misclassified as a + * lane has its ENTIRE downstream flow indented underneath itself, and the row it + * should have continued to never appears on the spine at all. + * + * Precedence under test (see the helper's own doc comment): + * 1. the registry's EXPLICIT default source handle (`isDefaultForType`), but + * only when that id is actually one of `candidates`; + * 2. a well-known `output` / `success` id; + * 3. the sole candidate, when there is exactly one. + * + * `handles(...)` builds the `{ id }[]` shape the helper takes, in manifest order, + * because manifest order is itself load-bearing (it is the order lanes render in, + * and it is what the `output` / `success` scan walks). + */ +const handles = (...ids: string[]): { id: string }[] => ids.map((id) => ({ id })); + +describe('resolveBranchHandleIds', () => { + describe('rule 1: an explicit registry default claims the spine', () => { + it('keeps a spine named `next` on the spine and lanes only the siblings', () => { + // The regression this rule exists for. Judged by name alone, `next` is not a + // recognized continuation, so BOTH handles would become lanes and the node's + // whole forward flow would indent under itself with no spine left. + expect(resolveBranchHandleIds(handles('next', 'error'), 'next')).toEqual(['error']); + }); + + it.each(['then', 'done', 'out'])('honours a spine named `%s`', (spineId) => { + expect(resolveBranchHandleIds(handles(spineId, 'error'), spineId)).toEqual(['error']); + }); + + it('outranks a literal `output` when both are present', () => { + // The registry default is the only AUTHORITATIVE statement a manifest makes + // about the forward flow, so it must beat the name heuristic rather than the + // other way round: here `output` is a real handle but `next` is the declared + // default, so `output` is the lane. + expect(resolveBranchHandleIds(handles('output', 'next'), 'next')).toEqual(['output']); + }); + + it('preserves manifest order across several lanes', () => { + expect(resolveBranchHandleIds(handles('try', 'catch', 'finally', 'cleanup'), 'try')).toEqual([ + 'catch', + 'finally', + 'cleanup', + ]); + // ...and the spine's position in the array does not change the lane order. + expect(resolveBranchHandleIds(handles('catch', 'finally', 'try', 'cleanup'), 'try')).toEqual([ + 'catch', + 'finally', + 'cleanup', + ]); + }); + }); + + describe('rule 1 guard: a default that is not a candidate is ignored', () => { + /** + * This is the important half of the fix. `candidates` has already been filtered + * to VISIBLE, non-artifact source handles, while the registry default is read + * straight off the manifest. So the default can legitimately name something + * that is not in `candidates`: a hidden handle, an artifact handle, or an + * unexpanded `repeat` template id. Trusting it blindly would set + * `continuationId` to an id no candidate matches, and then the + * `filter(id !== continuationId)` at the end would keep EVERY handle as a lane. + */ + it('falls through to the name heuristic instead of laning every handle', () => { + expect(resolveBranchHandleIds(handles('output', 'error'), 'artifact-out')).toEqual(['error']); + }); + + it('still resolves the sole candidate as the spine', () => { + // Rule 3 is the last line of defence for the one-handle case: without it a + // bogus default would indent a plain linear node's entire continuation. + expect(resolveBranchHandleIds(handles('next'), 'hidden-handle')).toEqual([]); + }); + + it('treats an unmatched default exactly like no default at all', () => { + expect(resolveBranchHandleIds(handles('output', 'error'), 'not-a-candidate')).toEqual( + resolveBranchHandleIds(handles('output', 'error'), undefined) + ); + }); + }); + + describe('rule 2: the `output` / `success` name heuristic', () => { + it('claims the spine for a manifest that flags no default', () => { + expect(resolveBranchHandleIds(handles('output', 'error'), undefined)).toEqual(['error']); + expect(resolveBranchHandleIds(handles('success', 'error'), undefined)).toEqual(['error']); + }); + + it('recognizes the well-known id wherever it sits in manifest order', () => { + expect(resolveBranchHandleIds(handles('error', 'output'), undefined)).toEqual(['error']); + expect(resolveBranchHandleIds(handles('true', 'false', 'success'), undefined)).toEqual([ + 'true', + 'false', + ]); + }); + + /** + * ACTUAL BEHAVIOR, asserted rather than asserted-as-desired. The heuristic is a + * single positional scan (`find(id === 'output' || id === 'success')`), so when + * a manifest declares BOTH ids the one appearing FIRST wins and the other + * becomes a lane. There is no canonical preference between them. + * + * Harmless in practice: `output` and `success` are synonyms for the same + * concept, so either choice yields a sensible spine, and no manifest in this + * repo declares both. But the outcome does depend on manifest ordering, so it + * is pinned here rather than left to be discovered by a reordered manifest. + * See this suite's reported findings. + */ + it('is order-sensitive when a manifest declares BOTH `output` and `success`', () => { + expect(resolveBranchHandleIds(handles('output', 'success'), undefined)).toEqual(['success']); + expect(resolveBranchHandleIds(handles('success', 'output'), undefined)).toEqual(['output']); + }); + }); + + describe('rule 3 / no signal at all', () => { + it('returns no lanes for a single candidate', () => { + expect(resolveBranchHandleIds(handles('output'), undefined)).toEqual([]); + // Rule 3 does not care what the handle is called. + expect(resolveBranchHandleIds(handles('whatever'), undefined)).toEqual([]); + }); + + it('returns no lanes for no candidates', () => { + expect(resolveBranchHandleIds(handles(), undefined)).toEqual([]); + expect(resolveBranchHandleIds(handles(), 'output')).toEqual([]); + }); + + /** + * ACCEPTED RESIDUAL LIMITATION, documented deliberately. With two or more + * handles, an unrecognized spine name, and no registry default, there is no + * signal at all for which handle is the continuation, so BOTH become lanes and + * the node gets no spine. This is strictly better than guessing (rule 1's doc + * explains why the positional `getDefaultHandle` fallback is refused: it would + * promote "True" to the spine on every Decision node), and the fix for a real + * manifest in this shape is to flag `isDefaultForType`, not to widen the name + * list here. + */ + it('lanes every handle when a spine is unnamed and undeclared', () => { + expect(resolveBranchHandleIds(handles('next', 'error'), undefined)).toEqual([ + 'next', + 'error', + ]); + }); + + it('correctly lanes both branches of a Decision, which HAS no spine', () => { + // The same "no signal" outcome, but here it is the RIGHT answer rather than a + // limitation: an If genuinely has no continuation output, which is why the + // absence of `isDefaultForType` must never be read as "this handle is a + // branch" in the opposite direction either. + expect(resolveBranchHandleIds(handles('true', 'false'), undefined)).toEqual([ + 'true', + 'false', + ]); + expect(resolveBranchHandleIds(handles('case1', 'case2', 'case3'), undefined)).toEqual([ + 'case1', + 'case2', + 'case3', + ]); + }); + }); + + it('never returns the continuation, and never drops or reorders a lane', () => { + // Invariant sweep over every precedence path: the result is always the input + // minus at most one id, with relative order intact. Guards against a future + // refactor that sorts, dedupes, or returns the spine by accident. + const cases: Array<[string[], string | undefined]> = [ + [['output', 'error'], undefined], + [['next', 'error'], 'next'], + [['output', 'true', 'false'], 'output'], + [['a', 'b', 'c'], 'missing'], + [['only'], undefined], + [[], 'output'], + ]; + for (const [ids, defaultId] of cases) { + const lanes = resolveBranchHandleIds(handles(...ids), defaultId); + expect(lanes.length).toBeGreaterThanOrEqual(Math.max(0, ids.length - 1)); + expect(lanes.length).toBeLessThanOrEqual(ids.length); + // A subsequence of the input, so order is preserved and nothing is invented. + expect(ids.filter((id) => lanes.includes(id))).toEqual(lanes); + } + }); +}); diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCanvas.insertParity.test.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCanvas.insertParity.test.ts new file mode 100644 index 000000000..e6a4128a7 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCanvas.insertParity.test.ts @@ -0,0 +1,358 @@ +import type { Edge, EdgeChange, Node, NodeChange } from '@uipath/apollo-react/canvas/xyflow/react'; +import { describe, expect, it } from 'vitest'; +import { DEFAULT_SOURCE_HANDLE_ID, DEFAULT_TARGET_HANDLE_ID } from '../../constants'; +import { NodeTypeRegistry } from '../../core'; +import { defaultWorkflowManifest } from '../../storybook-utils/manifests'; +import { sequentialWireframeManifests } from '../../storybook-utils/sequential/wireframeManifests'; +import { applyGraphChangeSet, makeWireframeFixture } from '../../utils/sequential/fixtures'; +import { SEQ_CONTINUATION_EDGE_KEY } from '../../utils/sequential/graph-helpers'; +import { insertAtSlot } from '../../utils/sequential/mutations'; +import type { GraphChangeSet, InsertionSlot } from '../../utils/sequential/sequential.types'; +import { + SEQ_INSERTED_FLAG, + SEQ_SPLIT_EDGE_ID_KEY, + sequentialOnBeforeNodeAdded, +} from './edges/sequentialInsert'; +import { + forwardSequentialEdgeChanges, + forwardSequentialNodeChanges, +} from './sequentialChangeFilters'; +import { deriveSequentialGraph } from './useSequentialGraph'; + +/** + * Equivalence test between the TWO insert paths that exist in this feature. + * + * `insertAtSlot` (utils/sequential/mutations.ts) is the pure semantic op, and it + * is used ONLY by tests. Production inserts never call it: they run through + * AddNodeManager -> `sequentialOnBeforeNodeAdded` -> `forwardSequential*Changes`. + * `SequentialCanvas.roundtrip.test.ts` uses `insertAtSlot` as a stand-in for + * "exactly what the change-forwarding produces" but never proves the two agree, + * which leaves the pure op's whole test suite anchored to an unverified claim. + * This file is that proof. + * + * The comparison is on CANONICAL TOPOLOGY, since that is all the projection ever + * reads back: the set of node ids, and the set of + * `source -> sourceHandle -> target -> targetHandle` tuples, plus the set of + * removed edge ids and the continuation markers. + * + * Two normalizations are applied, both for stated reasons rather than to make the + * test pass: + * + * 1. The inserted node's id. `sequentialOnBeforeNodeAdded` deliberately re-ids + * with `crypto.randomUUID()` (the pipeline seeds a `type-Date.now()` id that + * can collide inside one millisecond) and rebuilds every edge id from its + * endpoints, so ids can never match literally. + * 2. The INSERTED node's own handle ids. The two paths genuinely differ here and + * it is intentional: the production path keeps the handles AddNodeManager + * resolved from the inserted node's own manifest, while `insertAtSlot` leaves + * them `undefined` (default-handle semantics, resolved identically downstream + * by `findResolvedHandle` / `DEFAULT_*_HANDLE_ID`). The difference is asserted + * EXPLICITLY below rather than hidden, so it stays a documented equivalence + * and not a silent divergence. + * + * Everything else -- endpoints, the EXISTING nodes' canonical handles, the split + * edge removal, containment, and the continuation flags -- must match exactly. + */ + +const registry = new NodeTypeRegistry(); +registry.registerManifest( + [...defaultWorkflowManifest.nodes, ...sequentialWireframeManifests], + defaultWorkflowManifest.categories +); + +/** The node type the user picks in the Add Node panel for this test. */ +const INSERTED_TYPE = 'uipath.http-request'; + +interface EdgeTuple { + source: string; + sourceHandle?: string; + target: string; + targetHandle?: string; + continuation: boolean; +} + +/** + * Canonical topology of an edge, with `insertedId` collapsed to a stable token and + * the inserted node's OWN handle collapsed to `undefined` (see normalization 2). + */ +function edgeTuple(edge: Edge, insertedId: string): EdgeTuple { + const isFromInserted = edge.source === insertedId; + const isToInserted = edge.target === insertedId; + return { + source: isFromInserted ? '' : edge.source, + sourceHandle: isFromInserted ? undefined : (edge.sourceHandle ?? undefined), + target: isToInserted ? '' : edge.target, + targetHandle: isToInserted ? undefined : (edge.targetHandle ?? undefined), + continuation: + (edge.data as Record | undefined)?.[SEQ_CONTINUATION_EDGE_KEY] === true, + }; +} + +function sortTuples(tuples: EdgeTuple[]): EdgeTuple[] { + return [...tuples].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))); +} + +/** + * The `newNode` / `newEdges` AddNodeManager hands `onBeforeNodeAdded` for a slot + * that splits an existing edge (two `previewNodeConnectionInfo` entries, so two + * edges: existing -> preview, preview -> existing). + * + * The existing endpoints deliberately carry the WRONG handle ids here. In the + * real pipeline they come from the preview graph, whose handles are chosen for + * registry filtering and rendering rather than for canonical correctness (see + * `buildSequentialPreviewOptions`), so `sequentialOnBeforeNodeAdded` has to + * restore the slot's canonical handles. Feeding it bar-shaped handles is what + * makes that restoration observable instead of coincidental. + */ +function buildPipelineAddition(slot: InsertionSlot): { newNode: Node; newEdges: Edge[] } { + const previewMaterializedId = `${INSERTED_TYPE}-${Date.now()}`; + const insertedTargetHandle = registry.getDefaultHandle(INSERTED_TYPE, 'target')?.id; + const insertedSourceHandle = registry.getDefaultHandle(INSERTED_TYPE, 'source')?.id; + + const newNode: Node = { + id: previewMaterializedId, + type: INSERTED_TYPE, + position: { x: 12, y: 34 }, + selected: true, + data: { label: 'HTTP Request' }, + }; + const newEdges: Edge[] = [ + { + id: `edge_${slot.source?.nodeId}-seq-source-${previewMaterializedId}-${insertedTargetHandle}`, + source: slot.source?.nodeId ?? '', + sourceHandle: 'seq-source', + target: previewMaterializedId, + targetHandle: insertedTargetHandle, + type: 'default', + }, + { + id: `edge_${previewMaterializedId}-${insertedSourceHandle}-${slot.target?.nodeId}-seq-target`, + source: previewMaterializedId, + sourceHandle: insertedSourceHandle, + target: slot.target?.nodeId ?? '', + targetHandle: 'seq-target', + type: 'default', + }, + ]; + return { newNode, newEdges }; +} + +/** Applies a change stream to a canonical graph the way a controlled host does. */ +function applyForwardedChanges( + nodes: Node[], + edges: Edge[], + nodeChanges: NodeChange[], + edgeChanges: EdgeChange[] +): { nodes: Node[]; edges: Edge[] } { + const removedNodeIds = new Set( + nodeChanges.filter((change) => change.type === 'remove').map((change) => change.id) + ); + const removedEdgeIds = new Set( + edgeChanges.filter((change) => change.type === 'remove').map((change) => change.id) + ); + return { + nodes: [ + ...nodes.filter((node) => !removedNodeIds.has(node.id)), + ...nodeChanges.filter((change) => change.type === 'add').map((change) => change.item), + ], + edges: [ + ...edges.filter((edge) => !removedEdgeIds.has(edge.id)), + ...edgeChanges.filter((change) => change.type === 'add').map((change) => change.item), + ], + }; +} + +/** Applies a pure {@link GraphChangeSet} the same way. */ + +describe('sequential insert: production pipeline vs the pure insertAtSlot op', () => { + const { nodes, edges } = makeWireframeFixture(); + // The registry-driven predicates SequentialCanvas passes down, so the slots + // below are the ones production actually offers: containers keep their body + // scope (which is where `slot.containerId` comes from) and the canonical + // trigger is absorbed into the synthetic start row. + const projection = deriveSequentialGraph({ + nodes, + edges, + view: 'sequential', + isContainerNode: (node) => + registry.getManifest(node.type ?? '')?.display?.shape === 'container', + isStartNode: (node) => registry.getManifest(node.type ?? '')?.category === 'trigger', + }).projection; + + /** + * Two structurally different slots: a plain top-level step split, and a split + * INSIDE a container body (so `containerId` participates). Both are ordinary + * slots the projection offers, taken by their backing canonical edge id. + */ + const slotCases = [ + { name: 'a top-level step slot (HTTP -> Javascript)', graphEdgeId: 'e-http-js' }, + { name: 'a slot inside a container body (For Each -> If)', graphEdgeId: 'e-foreach-if' }, + ] as const; + + for (const slotCase of slotCases) { + describe(slotCase.name, () => { + const slot = projection?.slots.find( + (candidate) => candidate.graphEdgeId === slotCase.graphEdgeId + ); + + /** The production path: AddNodeManager -> onBeforeNodeAdded -> change forwarding. */ + function runProductionPath() { + const { newNode, newEdges } = buildPipelineAddition(slot as InsertionSlot); + const { newNode: finalNode, newEdges: finalEdges } = sequentialOnBeforeNodeAdded( + newNode, + newEdges, + { + 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, + } + ); + + const nodeChanges = forwardSequentialNodeChanges( + [{ type: 'add', item: finalNode }], + new Set(), + new Map(nodes.map((node) => [node.id, node])) + ); + const edgeChanges = forwardSequentialEdgeChanges( + finalEdges.map((edge) => ({ type: 'add' as const, item: edge })), + new Set(edges.map((edge) => edge.id)), + edges + ); + return { + insertedId: finalNode.id, + finalNode, + finalEdges, + nodeChanges, + edgeChanges, + graph: applyForwardedChanges(nodes, edges, nodeChanges, edgeChanges), + }; + } + + /** The pure path, given the same slot and an equivalent new node. */ + function runPurePath() { + const insertedId = 'pure-inserted'; + const changeSet = insertAtSlot( + projection as NonNullable, + slot as InsertionSlot, + { + id: insertedId, + type: INSERTED_TYPE, + position: { x: 0, y: 0 }, + data: { label: 'HTTP Request', [SEQ_INSERTED_FLAG]: true }, + } + ); + return { insertedId, changeSet, graph: applyGraphChangeSet({ nodes, edges }, changeSet) }; + } + + it('produces the same canonical topology', () => { + expect(slot).toBeDefined(); + const production = runProductionPath(); + const pure = runPurePath(); + + // Same node set (with each path's generated id collapsed). + const normalizeIds = (graph: { nodes: Node[] }, insertedId: string) => + graph.nodes.map((node) => (node.id === insertedId ? '' : node.id)).sort(); + expect(normalizeIds(production.graph, production.insertedId)).toEqual( + normalizeIds(pure.graph, pure.insertedId) + ); + + // Same edge topology, including the continuation markers that decide + // whether the downstream sequence stays on the spine or is reinterpreted + // as one of the inserted node's own lanes. + expect( + sortTuples(production.graph.edges.map((edge) => edgeTuple(edge, production.insertedId))) + ).toEqual(sortTuples(pure.graph.edges.map((edge) => edgeTuple(edge, pure.insertedId)))); + + // Same edge REMOVED: the split canonical edge, and only it. + const productionRemoved = production.edgeChanges + .filter((change) => change.type === 'remove') + .map((change) => change.id) + .sort(); + expect(productionRemoved).toEqual([...pure.changeSet.removeEdgeIds].sort()); + expect(productionRemoved).toEqual([slotCase.graphEdgeId]); + + // Same containment. + const productionInserted = production.graph.nodes.find( + (node) => node.id === production.insertedId + ); + const pureInserted = pure.graph.nodes.find((node) => node.id === pure.insertedId); + expect(productionInserted?.parentId).toBe(pureInserted?.parentId); + expect(productionInserted?.parentId).toBe(slot?.containerId); + }); + + it('restores the slot canonical handles on the existing endpoints', () => { + expect(slot).toBeDefined(); + const { insertedId, finalEdges } = runProductionPath(); + + const incoming = finalEdges.find((edge) => edge.target === insertedId); + const outgoing = finalEdges.find((edge) => edge.source === insertedId); + // The bar-shaped handles the pipeline supplied are gone; the slot's + // canonical handles are back. + expect(incoming?.sourceHandle).toBe(slot?.source?.handleId); + expect(outgoing?.targetHandle).toBe(slot?.target?.handleId); + expect(incoming?.sourceHandle).not.toBe('seq-source'); + expect(outgoing?.targetHandle).not.toBe('seq-target'); + }); + + it('keeps the inserted node manifest-resolved handles, where insertAtSlot leaves defaults', () => { + expect(slot).toBeDefined(); + const production = runProductionPath(); + const pure = runPurePath(); + + const productionIncoming = production.finalEdges.find( + (edge) => edge.target === production.insertedId + ); + const productionOutgoing = production.finalEdges.find( + (edge) => edge.source === production.insertedId + ); + const pureIncoming = pure.changeSet.addEdges.find( + (edge) => edge.target === pure.insertedId + ); + const pureOutgoing = pure.changeSet.addEdges.find( + (edge) => edge.source === pure.insertedId + ); + + // This is the ONE documented divergence between the two paths, pinned + // here so it cannot drift unnoticed: the production path carries the + // inserted node's manifest defaults... + expect(productionIncoming?.targetHandle).toBe( + registry.getDefaultHandle(INSERTED_TYPE, 'target')?.id + ); + expect(productionOutgoing?.sourceHandle).toBe( + registry.getDefaultHandle(INSERTED_TYPE, 'source')?.id + ); + // ...while the pure op leaves them undefined, which downstream resolves to + // the same handles via DEFAULT_*_HANDLE_ID / the registry default. + expect(pureIncoming?.targetHandle).toBeUndefined(); + expect(pureOutgoing?.sourceHandle).toBeUndefined(); + expect(productionIncoming?.targetHandle).toBe(DEFAULT_TARGET_HANDLE_ID); + expect(productionOutgoing?.sourceHandle).toBe(DEFAULT_SOURCE_HANDLE_ID); + }); + + it('strips the split marker and stamps the inserted flag before reaching the host', () => { + expect(slot).toBeDefined(); + const { nodeChanges, edgeChanges, insertedId } = runProductionPath(); + + // The node survives the node filter only because it is flagged + // `seqInserted`; every other `add` in this view is a derivation artifact. + expect(nodeChanges).toHaveLength(1); + const added = nodeChanges[0] as Extract; + expect(added.item.id).toBe(insertedId); + expect((added.item.data as Record)[SEQ_INSERTED_FLAG]).toBe(true); + + // The internal split marker is a message to the edge filter only, and must + // never land in the host's canonical edge data. + for (const change of edgeChanges) { + if (change.type !== 'add') continue; + expect( + (change.item.data as Record | undefined)?.[SEQ_SPLIT_EDGE_ID_KEY] + ).toBeUndefined(); + } + }); + }); + } +}); diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCanvas.integration.test.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCanvas.integration.test.tsx new file mode 100644 index 000000000..3b3a8e1df --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCanvas.integration.test.tsx @@ -0,0 +1,745 @@ +import { act, render, screen } from '@testing-library/react'; +import type { + Edge, + Node, + NodeChange, + OnEdgesChange, + OnNodesChange, +} from '@uipath/apollo-react/canvas/xyflow/react'; +import { applyEdgeChanges, applyNodeChanges } from '@uipath/apollo-react/canvas/xyflow/react'; +import { useState } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { PREVIEW_NODE_ID, SEQ_BAR_WIDTH } from '../../constants'; +import { NodeRegistryProvider } from '../../core'; +import { getToolbarActionStore } from '../../hooks/ToolbarActionContext'; +import type { NodeManifest } from '../../schema/node-definition'; +import type { ToolbarActionEvent } from '../../schema/toolbar'; +import { defaultWorkflowManifest } from '../../storybook-utils/manifests'; +import { sequentialWireframeManifests } from '../../storybook-utils/sequential/wireframeManifests'; +import { + makeDiamondFixture, + makeWireframeFixture, + WIREFRAME_NODE_IDS, +} from '../../utils/sequential/fixtures'; +import { SEQ_LANE_PLACEHOLDER_PREFIX } from '../../utils/sequential/graph-helpers'; +import type { CanvasView } from '../../utils/sequential/sequential.types'; +import { SequentialCanvas } from './SequentialCanvas'; +import { + SEQ_FULL_RENDER_MAX_NODES, + SEQ_PLACEHOLDER_ROW_ID, + SEQ_START_ROW_ID, +} from './sequentialGraph.constants'; + +/** + * Integration coverage for the two architectural seams of the Sequential Canvas + * view. Everything else in the feature is covered by pure-layer tests + * (`utils/sequential/*`) and leaf-component tests; this file is the only one that + * MOUNTS `SequentialCanvas` and drives it end to end. + * + * The two seams under test: + * + * - seam 1 (D4, derivation): the sequential view is a DERIVED projection. It + * must never write geometry back into canonical state, so a + * flow -> sequential -> flow round trip has to leave every canonical + * position / containment / dimension untouched even after xyflow reports a + * full mount burst of position and dimension changes. + * - seam 2 (change forwarding): xyflow reports every store mutation of the + * DERIVED arrays through onNodesChange / onEdgesChange. The canvas translates + * that stream before it reaches the host: view geometry is dropped, synthetic + * rows are dropped, and a `replace` on a real node merges only `data` + + * `selected` onto the CANONICAL node. + * + * ## Why the assertions are about props, not pixels + * + * `ReactFlow` is replaced with a capturing stub (the idiom in + * `BaseCanvas.test.tsx`), so no node component ever renders. That is not a + * limitation here: both seams are pure data flow. `capturedFlowProps.current` + * holds exactly what `BaseCanvas` handed xyflow, so tests read + * `.nodes` / `.edges` to inspect the derived arrays and CALL `.onNodesChange(...)` + * to play the role of xyflow reporting a change. That drives the real + * `handleNodesChange` -> `forwardSequentialNodeChanges` path and asserts what + * reaches the host's spy, which is the seam itself. + */ + +const { capturedFlowProps, mockReactFlowInstance } = vi.hoisted(() => ({ + capturedFlowProps: { + // biome-ignore lint/suspicious/noExplicitAny: holds whatever BaseCanvas passes ReactFlow + current: undefined as any, + }, + // SequentialCanvas reads the instance for placeholder positions and viewport + // save/restore only; neither is under test here, so a flat stub is enough. + mockReactFlowInstance: { + fitView: vi.fn(), + setViewport: vi.fn(), + getViewport: vi.fn(() => ({ x: 0, y: 0, zoom: 1 })), + getNode: vi.fn(() => undefined), + getNodes: vi.fn(() => []), + getEdges: vi.fn(() => []), + setNodes: vi.fn(), + setEdges: vi.fn(), + updateNodeData: vi.fn(), + updateNode: vi.fn(), + }, +})); + +vi.mock('@uipath/apollo-react/canvas/xyflow/react', async (importOriginal) => ({ + ...(await importOriginal()), + // Capture the props instead of rendering a flow. `nodes` / `edges` / the change + // handlers are the whole surface these tests need. + // biome-ignore lint/suspicious/noExplicitAny: test stub + ReactFlow: (props: any) => { + capturedFlowProps.current = props; + return
    {props.children}
    ; + }, + Background: () =>
    , + // The real portal targets a DOM node the stubbed flow never renders; render the + // gutter inline instead so it is still exercised on every mount. + // biome-ignore lint/suspicious/noExplicitAny: test stub + ViewportPortal: ({ children }: any) =>
    {children}
    , + useReactFlow: () => mockReactFlowInstance, +})); + +/** + * A node whose spine output is named `next` rather than `output` / `success`, and + * which declares that fact the only way a manifest can: `isDefaultForType`. + * + * This manifest exists because NO Storybook manifest discriminates + * `resolveBranchHandleIds` rule 1. `uipath.agent` is the only one that flags a + * default, and its spine happens to be called `success`, so rule 2 would resolve + * it even if rule 1 were removed entirely. Without a `next`-named spine there is + * no mounted-canvas coverage of the gate at all, which is precisely the case the + * fix was written for. + */ +const CUSTOM_SPINE_NODE_TYPE = 'test.custom-spine'; +const customSpineManifest: NodeManifest = { + nodeType: CUSTOM_SPINE_NODE_TYPE, + version: '1', + category: 'connector', + tags: ['test'], + description: 'A node whose forward output is named `next` and flagged as the type default', + display: { label: 'Custom Spine', icon: 'globe' }, + handleConfiguration: [ + { + position: 'left', + handles: [{ id: 'input', type: 'target', handleType: 'input' }], + }, + { + position: 'right', + handles: [ + // Deliberately NOT named `output` / `success`, so the name heuristic cannot + // rescue it and only the flagged default can identify the spine. + { id: 'next', type: 'source', handleType: 'output', isDefaultForType: true }, + { id: 'error', label: 'Error', type: 'source', handleType: 'output' }, + ], + }, + ], +}; + +/** + * The wireframe fixture uses two node types ("HTTP Request", "Send Message to + * User") that have no Storybook manifest of their own. Merged additively, the + * same way `SequentialCanvasStoryHarness` does it, so the shared + * `defaultWorkflowManifest` every other canvas test relies on is untouched. + */ +const testManifest = { + ...defaultWorkflowManifest, + nodes: [...defaultWorkflowManifest.nodes, ...sequentialWireframeManifests, customSpineManifest], +}; + +/** Spies the harness wraps the host callbacks in, re-created per test. */ +let hostNodesChange: ReturnType; +let hostEdgesChange: ReturnType; +let hostToolbarAction: ReturnType; + +/** + * Live mirror of the harness's canonical state, written during render. Lets a + * test read the canonical graph after driving changes through the seam without + * threading a callback out of the component tree. + */ +const canonical: { nodes: Node[]; edges: Edge[] } = { nodes: [], edges: [] }; + +interface HarnessProps { + initialNodes: Node[]; + initialEdges: Edge[]; + view: CanvasView; + mode?: 'design' | 'view' | 'readonly'; + /** + * Set false to model a host that owns mutations itself and wires neither change + * callback. The canvas then has no channel to emit on, which is a distinct case + * from "not in design mode" for anything that reports whether it acted. + */ + wireChangeHandlers?: boolean; +} + +/** + * A realistic controlled host: canonical `nodes`/`edges` in state, mutated ONLY + * through xyflow's own reducers, with the host callbacks wrapped in spies. This + * is the shape every consumer is expected to have (and what + * `SequentialCanvasStoryHarness` does), so a change that only "works" against a + * stubbed host would fail here. + */ +function ControlledHarness({ + initialNodes, + initialEdges, + view, + mode = 'design', + wireChangeHandlers = true, +}: HarnessProps) { + const [nodes, setNodes] = useState(initialNodes); + const [edges, setEdges] = useState(initialEdges); + canonical.nodes = nodes; + canonical.edges = edges; + + const onNodesChange: OnNodesChange = (changes) => { + hostNodesChange(changes); + setNodes((current) => applyNodeChanges(changes, current)); + }; + const onEdgesChange: OnEdgesChange = (changes) => { + hostEdgesChange(changes); + setEdges((current) => applyEdgeChanges(changes, current)); + }; + + return ( + + ); +} + +function renderCanvas(props: HarnessProps) { + const result = render( + + + + ); + const rerenderWith = (next: Partial) => + result.rerender( + + + + ); + return { ...result, rerenderWith }; +} + +/** The derived arrays `BaseCanvas` last handed xyflow. */ +const derivedNodes = (): Node[] => capturedFlowProps.current.nodes; +const derivedNodeIds = (): string[] => derivedNodes().map((node) => node.id); + +/** Plays the role of xyflow reporting node changes on the derived array. */ +function reportNodeChanges(changes: NodeChange[]): void { + act(() => { + capturedFlowProps.current.onNodesChange(changes); + }); +} + +/** Every canonical field the sequential view is forbidden to write (D4). */ +function geometryOf(nodes: readonly Node[]) { + return nodes.map((node) => ({ + id: node.id, + position: { ...node.position }, + parentId: node.parentId, + extent: node.extent, + width: node.width, + height: node.height, + })); +} + +/** All node changes the host received, flattened across every call. */ +function forwardedNodeChanges(): NodeChange[] { + return hostNodesChange.mock.calls.flatMap((call) => call[0] as NodeChange[]); +} + +function changeIds(changes: readonly NodeChange[], type: NodeChange['type']): string[] { + return changes + .filter((change) => change.type === type) + .map((change) => ('id' in change ? change.id : change.item.id)); +} + +/** A flat chain of single-output nodes, so no branch-lane placeholder rows are added. */ +function makeChainFixture(length: number): { nodes: Node[]; edges: Edge[] } { + const nodes: Node[] = Array.from({ length }, (_, index) => ({ + id: `step-${index}`, + type: 'uipath.http-request', + position: { x: 0, y: index * 100 }, + data: { display: { label: `Step ${index}` } }, + })); + const edges: Edge[] = nodes.slice(1).map((node, index) => ({ + id: `chain-${index}`, + source: `step-${index}`, + sourceHandle: 'output', + target: node.id, + targetHandle: 'input', + })); + return { nodes, edges }; +} + +/** + * An agent node feeding one downstream step. `uipath.agent` is the one manifest in + * the Storybook set that flags `isDefaultForType` on its spine (`success`), and it + * also carries five `artifact` source handles, so it is the real production case + * for `resolveBranchHandleIds` rule 1. + */ +function makeAgentFixture(): { nodes: Node[]; edges: Edge[] } { + return { + nodes: [ + { + id: 'agent', + type: 'uipath.agent', + position: { x: 0, y: 0 }, + data: { display: { label: 'Agent' } }, + }, + { + id: 'after', + type: 'uipath.http-request', + position: { x: 0, y: 200 }, + data: { display: { label: 'After' } }, + }, + ], + edges: [ + { + id: 'agent-after', + source: 'agent', + sourceHandle: 'success', + target: 'after', + targetHandle: 'input', + }, + ], + }; +} + +/** ` --next--> `, plus an untouched `error` lane. */ +function makeCustomSpineFixture(): { nodes: Node[]; edges: Edge[] } { + return { + nodes: [ + { + id: 'spine', + type: CUSTOM_SPINE_NODE_TYPE, + position: { x: 0, y: 0 }, + data: { display: { label: 'Custom Spine' } }, + }, + { + id: 'after', + type: 'uipath.http-request', + position: { x: 0, y: 200 }, + data: { display: { label: 'After' } }, + }, + ], + edges: [ + { + id: 'spine-after', + source: 'spine', + sourceHandle: 'next', + target: 'after', + targetHandle: 'input', + }, + ], + }; +} + +beforeEach(() => { + hostNodesChange = vi.fn(); + hostEdgesChange = vi.fn(); + hostToolbarAction = vi.fn(); + capturedFlowProps.current = undefined; + canonical.nodes = []; + canonical.edges = []; +}); + +describe('SequentialCanvas view toggle (seam 1, D4)', () => { + it('leaves every canonical position, containment, and dimension untouched across a flow -> sequential -> flow round trip', () => { + const { nodes, edges } = makeWireframeFixture(); + const { rerenderWith } = renderCanvas({ + initialNodes: nodes, + initialEdges: edges, + view: 'flow', + }); + + // Flow view passes the canonical graph straight through, so this snapshot is + // the real canonical geometry, taken before sequential has ever derived. + const before = geometryOf(canonical.nodes); + const identitiesBefore = new Map(canonical.nodes.map((node) => [node.id, node])); + expect(before).toHaveLength(nodes.length); + + rerenderWith({ view: 'sequential' }); + + // The mount burst xyflow really produces: it measures every rendered bar and + // reports a position + dimensions change for each one. In sequential view + // those describe LAYOUT-owned geometry of a flattened 896px clone, so + // forwarding any of them would overwrite the canonical graph. Drive the + // whole burst through the seam at once. + const realRowIds = derivedNodeIds().filter( + (id) => id !== SEQ_START_ROW_ID && id !== SEQ_PLACEHOLDER_ROW_ID + ); + // Guards the test against silently going vacuous: the burst has to actually + // target every canonical row (the trigger is absorbed into the start bar, so + // it has no row of its own). The neighbouring `select` test proves this same + // channel is live, so nothing here can pass merely by never arriving. + for (const id of Object.values(WIREFRAME_NODE_IDS)) { + if (id === WIREFRAME_NODE_IDS.trigger) continue; + expect(realRowIds).toContain(id); + } + reportNodeChanges([ + ...realRowIds.map( + (id, index): NodeChange => ({ + id, + type: 'position', + position: { x: 64, y: index * 120 }, + dragging: false, + }) + ), + ...realRowIds.map( + (id): NodeChange => ({ + id, + type: 'dimensions', + dimensions: { width: SEQ_BAR_WIDTH, height: 56 }, + resizing: false, + }) + ), + ]); + + rerenderWith({ view: 'flow' }); + + expect(geometryOf(canonical.nodes)).toEqual(before); + // Stronger than value equality and the real promise of D4: canonical nodes + // are not even re-created, because the seam produced no forwardable change + // at all and the host's reducer was never invoked. + for (const node of canonical.nodes) { + expect(node).toBe(identitiesBefore.get(node.id)); + } + }); + + it('disables connections in sequential view and restores them in flow view (documented v1 constraint)', () => { + const { nodes, edges } = makeWireframeFixture(); + const { rerenderWith } = renderCanvas({ + initialNodes: nodes, + initialEdges: edges, + view: 'sequential', + }); + expect(capturedFlowProps.current.nodesConnectable).toBe(false); + + rerenderWith({ view: 'flow' }); + expect(capturedFlowProps.current.nodesConnectable).toBe(true); + }); +}); + +describe('SequentialCanvas change forwarding (seam 2)', () => { + it('drops position and dimension changes, and passes selection through', () => { + const { nodes, edges } = makeWireframeFixture(); + renderCanvas({ initialNodes: nodes, initialEdges: edges, view: 'sequential' }); + + const target = WIREFRAME_NODE_IDS.javascript; + expect(derivedNodeIds()).toContain(target); + + reportNodeChanges([ + { id: target, type: 'position', position: { x: 999, y: 999 }, dragging: true }, + { id: target, type: 'dimensions', dimensions: { width: 12, height: 34 }, resizing: true }, + ]); + + // The canvas may not call the host at all when nothing survives the filter; + // what matters is that no position/dimension change ever reaches it. + const geometryChanges = forwardedNodeChanges().filter( + (change) => change.type === 'position' || change.type === 'dimensions' + ); + expect(geometryChanges).toEqual([]); + + // Selection is real canonical state and must pass through untouched, which is + // what keeps keyboard nav and pointer selection indistinguishable to the host. + reportNodeChanges([{ id: target, type: 'select', selected: true }]); + expect(hostNodesChange).toHaveBeenCalledWith([{ id: target, type: 'select', selected: true }]); + expect(canonical.nodes.find((node) => node.id === target)?.selected).toBe(true); + }); + + it('rewrites a replace to merge only data and selected onto the canonical node', () => { + const { nodes, edges } = makeWireframeFixture(); + renderCanvas({ initialNodes: nodes, initialEdges: edges, view: 'sequential' }); + + // `if` is the interesting case: it is a CONTAINED node (parentId: for-each), + // and the sequential clone is flattened, so a verbatim replace would erase + // the containment the flow view depends on. + const targetId = WIREFRAME_NODE_IDS.ifNode; + const canonicalBefore = canonical.nodes.find((node) => node.id === targetId); + expect(canonicalBefore?.parentId).toBe(WIREFRAME_NODE_IDS.forEach); + const derivedClone = derivedNodes().find((node) => node.id === targetId); + // Sanity-check that the clone really carries view-only geometry, otherwise + // the assertion below would pass for the wrong reason. + expect(derivedClone?.width).toBe(SEQ_BAR_WIDTH); + expect(derivedClone?.parentId).toBeUndefined(); + expect(derivedClone?.draggable).toBe(false); + + // What an inline rename via `updateNodeData` produces: a replace whose item + // is the derived clone with new `data`. + const renamed: Node = { + ...(derivedClone as Node), + data: { display: { label: 'Renamed' } }, + selected: true, + }; + reportNodeChanges([{ id: targetId, type: 'replace', item: renamed }]); + + const replaces = forwardedNodeChanges().filter((change) => change.type === 'replace'); + expect(replaces).toHaveLength(1); + const forwarded = replaces[0] as Extract; + + // Only the two host-owned fields come from the clone... + expect(forwarded.item.data).toEqual({ display: { label: 'Renamed' } }); + expect(forwarded.item.selected).toBe(true); + // ...every geometry / structure field comes from the CANONICAL node. + expect(forwarded.item.position).toEqual(canonicalBefore?.position); + expect(forwarded.item.parentId).toBe(WIREFRAME_NODE_IDS.forEach); + expect(forwarded.item.width).toBe(canonicalBefore?.width); + expect(forwarded.item.draggable).toBe(canonicalBefore?.draggable); + expect(forwarded.item.type).toBe(canonicalBefore?.type); + + // And the containment survives in the host's state after the reducer runs. + expect(canonical.nodes.find((node) => node.id === targetId)?.parentId).toBe( + WIREFRAME_NODE_IDS.forEach + ); + }); + + it('renders synthetic rows but never forwards changes that reference them', () => { + const { nodes, edges } = makeWireframeFixture(); + renderCanvas({ initialNodes: nodes, initialEdges: edges, view: 'sequential' }); + + // (a) The derivation injects them: the start bar, the terminal placeholder, + // and one lane placeholder per empty branch lane (every `uipath.script` + // node declares an unused `error` lane). + const ids = derivedNodeIds(); + expect(ids).toContain(SEQ_START_ROW_ID); + expect(ids).toContain(SEQ_PLACEHOLDER_ROW_ID); + const lanePlaceholderIds = ids.filter((id) => id.startsWith(SEQ_LANE_PLACEHOLDER_PREFIX)); + expect(lanePlaceholderIds.length).toBeGreaterThan(0); + // The canonical trigger is absorbed INTO the synthetic start bar, so it must + // not appear as a row of its own. + expect(ids).not.toContain(WIREFRAME_NODE_IDS.trigger); + + // The Add Node pipeline drops its `preview` node in through the same change + // stream (this canvas is controlled, so `instance.setNodes` arrives as an + // `add` change). Driving the real `add` proves the local-capture path, not + // just the filter. + const previewNode: Node = { + id: PREVIEW_NODE_ID, + type: 'preview', + position: { x: 0, y: 0 }, + data: {}, + }; + reportNodeChanges([{ type: 'add', item: previewNode }]); + expect(derivedNodeIds()).toContain(PREVIEW_NODE_ID); + + // (b) Nothing referencing any of them may reach the host. + hostNodesChange.mockClear(); + const syntheticIds = [ + SEQ_START_ROW_ID, + SEQ_PLACEHOLDER_ROW_ID, + PREVIEW_NODE_ID, + ...lanePlaceholderIds, + ]; + reportNodeChanges([ + ...syntheticIds.map((id): NodeChange => ({ id, type: 'select', selected: true })), + ...syntheticIds.map((id): NodeChange => ({ id, type: 'remove' })), + ]); + const referenced = forwardedNodeChanges().filter((change) => + syntheticIds.includes('id' in change ? change.id : change.item.id) + ); + expect(referenced).toEqual([]); + // And no synthetic id leaked into canonical state along the way. + for (const id of syntheticIds) { + expect(canonical.nodes.some((node) => node.id === id)).toBe(false); + } + }); +}); + +describe('SequentialCanvas toolbar delete', () => { + /** + * The toolbar reaches the canvas through the module-level toolbar action store + * (`useToolbarActionStore`, wired by BaseCanvas), not through a rendered + * button, and the sequential bars are never rendered under the stubbed flow. + * Reading the handler back out of that store dispatches through exactly the + * function the real toolbar resolver calls. + */ + const dispatchToolbarAction = (event: ToolbarActionEvent) => { + const handler = getToolbarActionStore().onToolbarAction; + expect(handler).toBeTypeOf('function'); + act(() => { + handler?.(event); + }); + }; + + it('cascades a branch owner and its lane descendants, and heals the seam', () => { + // The diamond is the clean case: A -> If; If.true -> B -> D; If.false -> C -> D. + // Deleting `If` must take BOTH populated lanes with it and reconnect A to D. + const { nodes, edges } = makeDiamondFixture(); + renderCanvas({ initialNodes: nodes, initialEdges: edges, view: 'sequential', mode: 'design' }); + + dispatchToolbarAction({ actionId: 'delete', nodeId: 'if', mode: 'design' }); + + const removedIds = changeIds(forwardedNodeChanges(), 'remove'); + expect(removedIds).toContain('if'); + expect(removedIds).toContain('b'); + expect(removedIds).toContain('c'); + // The upstream node and the merge point survive. + expect(removedIds).not.toContain('a'); + expect(removedIds).not.toContain('d'); + expect(canonical.nodes.map((node) => node.id).sort()).toEqual(['a', 'd']); + + // The seam is healed rather than left dangling: A now feeds D directly, and + // every edge that touched a removed node is gone. + const healed = canonical.edges.find((edge) => edge.source === 'a' && edge.target === 'd'); + expect(healed).toBeDefined(); + const survivingIds = new Set(canonical.nodes.map((node) => node.id)); + for (const edge of canonical.edges) { + expect(survivingIds.has(edge.source)).toBe(true); + expect(survivingIds.has(edge.target)).toBe(true); + } + + // The canvas handled it, so the host's own handler is NOT also invoked. + expect(hostToolbarAction).not.toHaveBeenCalled(); + }); + + it('falls through to the host handler for a delete raised outside design mode', () => { + // The interception guard lives only in `deleteStep`, which returns whether it + // acted; a `delete` it cannot act on must reach the host instead of being + // silently swallowed. + const { nodes, edges } = makeDiamondFixture(); + renderCanvas({ initialNodes: nodes, initialEdges: edges, view: 'sequential', mode: 'view' }); + + const event: ToolbarActionEvent = { actionId: 'delete', nodeId: 'if', mode: 'view' }; + dispatchToolbarAction(event); + + expect(hostToolbarAction).toHaveBeenCalledWith(event); + // Nothing was deleted behind the host's back. + expect(canonical.nodes).toHaveLength(nodes.length); + expect(forwardedNodeChanges().filter((change) => change.type === 'remove')).toEqual([]); + }); + + it('always forwards a non-delete action to the host', () => { + const { nodes, edges } = makeDiamondFixture(); + renderCanvas({ initialNodes: nodes, initialEdges: edges, view: 'sequential', mode: 'design' }); + + const event: ToolbarActionEvent = { actionId: 'duplicate', nodeId: 'b', mode: 'design' }; + dispatchToolbarAction(event); + + expect(hostToolbarAction).toHaveBeenCalledWith(event); + }); + + it('falls through to the host handler when no change callback is wired', () => { + // Design mode and a live projection, but no onNodesChange/onEdgesChange: there + // is no channel to emit the removal on, so `deleteStep` has not acted and must + // not claim the action. A host that owns mutations itself and handles `delete` + // through onToolbarAction alone would otherwise see it silently swallowed. + const { nodes, edges } = makeDiamondFixture(); + renderCanvas({ + initialNodes: nodes, + initialEdges: edges, + view: 'sequential', + mode: 'design', + wireChangeHandlers: false, + }); + + const event: ToolbarActionEvent = { actionId: 'delete', nodeId: 'if', mode: 'design' }; + dispatchToolbarAction(event); + + expect(hostToolbarAction).toHaveBeenCalledWith(event); + expect(canonical.nodes).toHaveLength(nodes.length); + }); +}); + +describe('SequentialCanvas virtualization ceiling', () => { + const ACCESSIBLE_LIST_LABEL = 'Workflow steps'; + + it('renders every row into the DOM below SEQ_FULL_RENDER_MAX_NODES (D8)', () => { + // 140 chain steps + the synthetic start bar + the terminal placeholder, which + // must stay under the ceiling for this case to mean anything. + const { nodes, edges } = makeChainFixture(140); + renderCanvas({ initialNodes: nodes, initialEdges: edges, view: 'sequential' }); + + expect(derivedNodes().length).toBeLessThanOrEqual(SEQ_FULL_RENDER_MAX_NODES); + expect(capturedFlowProps.current.onlyRenderVisibleElements).toBe(false); + expect(capturedFlowProps.current['aria-hidden']).toBeUndefined(); + // Not aria-hidden, so the rows stay in the tab order and ARE the reading order. + expect(capturedFlowProps.current.nodesFocusable).toBe(true); + expect(capturedFlowProps.current.edgesFocusable).toBe(true); + expect(screen.queryByRole('list', { name: ACCESSIBLE_LIST_LABEL })).toBeNull(); + }); + + it('re-enables virtualization above the ceiling and substitutes the accessible list', () => { + const { nodes, edges } = makeChainFixture(160); + renderCanvas({ initialNodes: nodes, initialEdges: edges, view: 'sequential' }); + + expect(derivedNodes().length).toBeGreaterThan(SEQ_FULL_RENDER_MAX_NODES); + expect(capturedFlowProps.current.onlyRenderVisibleElements).toBe(true); + // The visual subtree is hidden from assistive tech, so the list below is the + // only reading order that remains. + expect(capturedFlowProps.current['aria-hidden']).toBe(true); + // ...which means it must also leave the tab order. xyflow defaults both of + // these to true and renders tabIndex=0 on each wrapper; keeping that inside an + // aria-hidden subtree is the `aria-hidden-focus` violation, i.e. 160 controls a + // keyboard user reaches but no screen reader can announce. + expect(capturedFlowProps.current.nodesFocusable).toBe(false); + expect(capturedFlowProps.current.edgesFocusable).toBe(false); + const list = screen.getByRole('list', { name: ACCESSIBLE_LIST_LABEL }); + expect(list.querySelectorAll('li')).toHaveLength(160); + }); +}); + +describe('SequentialCanvas branch-lane resolution wiring', () => { + /** + * `resolveBranchHandleIds` is unit-tested exhaustively in + * `SequentialCanvas.branchHandles.test.ts`. What that cannot cover is the SEAM + * between it and the registry: `getBranchHandles` filters the candidate handles + * to visible non-artifact sources, and passes the registry default ONLY when the + * manifest actually set `isDefaultForType`. These two tests assert that wiring + * end to end through a mounted canvas, observed as the indent depth the + * downstream row lands at. + */ + it('keeps a `next`-named spine on the spine because the manifest flags it (rule 1)', () => { + // The headline regression, end to end. Before the fix a spine named + // `next` / `then` / `done` / `out` alongside any second source handle was + // classified as a LANE, so the node's entire forward flow was indented + // underneath it and the spine was left empty. + const { nodes, edges } = makeCustomSpineFixture(); + renderCanvas({ initialNodes: nodes, initialEdges: edges, view: 'sequential' }); + + const ids = derivedNodeIds(); + // `error` is a genuine empty lane, so lane detection is demonstrably live. + expect(ids).toContain(`${SEQ_LANE_PLACEHOLDER_PREFIX}spine::error`); + + // `next` carries the forward flow, so `after` must sit at the spine's own + // indent depth. If the `isDefaultForType` gate stopped being wired, rule 2 + // could not rescue a handle named `next` and `after` would indent one level. + const spineRow = derivedNodes().find((node) => node.id === 'spine'); + const afterRow = derivedNodes().find((node) => node.id === 'after'); + expect(spineRow?.position.x).toBe(0); + expect(afterRow?.position.x).toBe(0); + }); + + it('filters artifact source handles out before lane resolution', () => { + const { nodes, edges } = makeAgentFixture(); + renderCanvas({ initialNodes: nodes, initialEdges: edges, view: 'sequential' }); + + const ids = derivedNodeIds(); + // `error` is a genuine empty lane, so it gets a placeholder row: proof that + // lane detection is live at all and this test is not passing by doing nothing. + expect(ids).toContain(`${SEQ_LANE_PLACEHOLDER_PREFIX}agent::error`); + // The five artifact source handles are filtered out before the helper sees + // them, so none may become a lane. + for (const artifactHandle of ['memory', 'memory2', 'escalation', 'context', 'tools']) { + expect(ids).not.toContain(`${SEQ_LANE_PLACEHOLDER_PREFIX}agent::${artifactHandle}`); + } + + // This is the assertion with the teeth. `success` is POPULATED (it feeds + // `after`), so laning it would produce no placeholder row to look for. It + // would instead push `after` down one indent level. Depth 0 rows sit at x=0 + // and each level indents by SEQ_INDENT_PX, so equal x is exactly the claim + // "the agent's forward flow stayed on the spine" -- which is what breaks if + // the `isDefaultForType` gate or the candidate guard regresses. + const agentRow = derivedNodes().find((node) => node.id === 'agent'); + const afterRow = derivedNodes().find((node) => node.id === 'after'); + expect(agentRow?.position.x).toBe(0); + expect(afterRow?.position.x).toBe(agentRow?.position.x); + }); +}); 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..32c8feb4c --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCanvas.roundtrip.test.ts @@ -0,0 +1,120 @@ +import type { Edge, Node } from '@uipath/apollo-react/canvas/xyflow/react'; +import { describe, expect, it } from 'vitest'; +import { sequenceFingerprint } from '../../utils/sequential/fingerprint'; +import { + applyGraphChangeSet, + 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 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 = applyGraphChangeSet({ 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..539f80f35 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCanvas.tsx @@ -0,0 +1,872 @@ +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(); + +/** + * Splits a node's visible, non-artifact source handles into the ONE continuation + * (spine) output and the branch lanes, returned here in manifest order. Exported + * as a pure helper so the precedence below is testable without mounting a canvas. + * + * Precedence for the continuation: + * 1. the registry's EXPLICIT default source handle (`isDefaultForType`), the + * only authoritative statement a manifest makes about which output carries + * the forward flow -- this is what lets a spine named `next` / `then` / + * `done` / `out` keep its place on the spine; + * 2. a well-known `output` / `success` id, for manifests that flag nothing; + * 3. the sole candidate, when the node has exactly one source. + * + * The registry default is honoured only when it is itself one of `candidates`: a + * default pointing at a hidden handle, an artifact handle, or an unexpanded + * `repeat` template id must not leave EVERY real handle classified as a lane, + * which would indent a node's whole forward flow under itself. + * + * Note the caller passes the default id only when the manifest actually set + * `isDefaultForType`. `getDefaultHandle`'s positional fallback (first source + * handle when nothing is flagged) is deliberately NOT accepted: control-flow + * manifests flag no default (If → true/false, switch → cases), so the fallback + * would silently promote the FIRST lane ("True") to the spine and drop it as a + * lane. `isDefaultForType` is likewise not a branch discriminator in the other + * direction -- it describes default connection selection, not control-flow + * semantics, so its ABSENCE never means "this handle is a branch". + */ +export function resolveBranchHandleIds( + candidates: readonly { id: string }[], + registryDefaultSourceHandleId: string | undefined +): string[] { + // One `find` per documented rule, in precedence order, so the code reads as the + // same three-item list the doc above states. Rule 1 needs no separate "is it a + // candidate" test: a `registryDefaultSourceHandleId` that is absent (or + // `undefined`) simply matches nothing and falls through, which is exactly the + // guard described above. + const continuationId = + candidates.find((handle) => handle.id === registryDefaultSourceHandleId)?.id ?? + 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) => handle.id); +} + +/** + * 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 + * SequentialCanvasStoryHarness in canvas/storybook-utils/sequential, which drives + * the "Interactive Sequence" story, and SequentialViewProvider for the persisted + * variant). 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]); + + // Known tradeoff, deliberately not optimized: this re-resolves `resolveHandles` + // for EVERY node whenever the `nodes` array identity changes -- including a + // single rename keystroke, which produces a fresh array. That churns the + // identity of `getBranchHandles` / `resolveBranchLabel`, which in turn re-runs + // several O(n) fingerprint string-building passes in useSequentialGraph. The + // structural projection itself is correctly NOT recomputed (that is exactly what + // the D12 fingerprint buys), but the cost of PROVING it is O(n) per keystroke. + // Acceptable below the SEQ_FULL_RENDER_MAX_NODES (150) ceiling; if the + // sequential view ever feels sluggish while typing, profile this first (the fix + // is a per-node cache keyed on node.id + the data fields resolveHandles reads). + 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. + // Which source handle is the continuation (and so NOT a lane) is decided by + // resolveBranchHandleIds; see its doc for the precedence and for why only an + // explicitly flagged registry default is trusted. The registry lookup mirrors + // the other `getDefaultHandle` call sites in this file (findResolvedHandle, + // onLaneAdd, tailSlot) so all four agree on what a node's spine output is. + 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 defaultHandle = node.type ? registry?.getDefaultHandle(node.type, 'source') : undefined; + const laneIds = resolveBranchHandleIds( + candidates, + defaultHandle?.isDefaultForType ? defaultHandle.id : undefined + ); + return laneIds.map((id) => ({ id, label: resolveBranchLabel(node.id, id) })); + }, + [resolvedHandlesByNodeId, resolveBranchLabel, registry] + ); + + 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]); + // Render-phase ref write, and safe to keep as one: it is idempotent -- the value + // stored is derived purely from THIS render's inputs, never folded over render + // history -- so a StrictMode double-invoke or a discarded concurrent render + // writes the identical value. Deliberately not moved into an effect: the tail + // "Add step" placeholder is built during this same render pass, so an effect + // would leave the slot undefined (dead placeholder) until after first paint. + 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] + ); + + // Returns whether the delete was actually handled here. The guard lives ONLY in + // this function, and `handleToolbarAction` below branches on the return value, + // so a `delete` raised in view/readonly mode (or in flow view, or before the + // projection exists) falls THROUGH to the host's onToolbarAction instead of + // being silently swallowed by an interception that could not act on it. + // + // A host with NEITHER change callback wired is the same case: there is no + // channel to emit the removal on, so this cannot claim the action. Reporting + // `true` there would swallow `delete` for a host that handles it through + // `onToolbarAction` alone (a plausible shape: design mode, host-owned + // mutations), which is exactly the failure the return value exists to prevent. + const deleteStep = useCallback( + (nodeId: string) => { + if (!isDesignMode || view !== 'sequential' || !projection) return false; + if (!onNodesChange && !onEdgesChange) return false; + const changeSet = removeStep(projection, nodeId, { nodes, edges }); + onNodesChange?.(graphChangeSetToNodeChanges(changeSet)); + onEdgesChange?.(graphChangeSetToEdgeChanges(changeSet)); + return true; + }, + [isDesignMode, view, projection, nodes, edges, onNodesChange, onEdgesChange] + ); + + const handleToolbarAction = useCallback< + NonNullable['onToolbarAction']> + >( + (event) => { + if (event.actionId === 'delete' && deleteStep(event.nodeId)) return; + onToolbarAction?.(event); + }, + [deleteStep, onToolbarAction] + ); + + // 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, + }); + + // 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} + // MUST move in lockstep with the `aria-hidden` above. xyflow's + // `nodesFocusable`/`edgesFocusable` default to true, which renders + // `tabIndex={0}` on every node/edge wrapper; leaving that on inside an + // aria-hidden subtree is the `aria-hidden-focus` violation (a keyboard + // user tabs through controls no screen reader can see). Above the + // ceiling SequentialAccessibleList is the operable surface instead, so + // the canvas must drop out of the tab order entirely. + nodesFocusable={!virtualizeForScale} + edgesFocusable={!virtualizeForScale} + // 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..05198b92a --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialMoveActionsContext.tsx @@ -0,0 +1,37 @@ +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; + // Deliberately NOT part of v1: a `centerOnNode` viewport-centering action for + // goto reference chips (D9). The chip UI was cut from this PR, so the plumbing + // was removed rather than left as an unreachable branch. Re-add it here, and in + // useSequentialMoveActionsValue, only alongside the UI that actually calls it. +} + +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 -- 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/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..143a72f8e --- /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.className).toContain('border-[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..513a3c8d4 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialConnectorEdge.test.tsx @@ -0,0 +1,146 @@ +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('insert button accessible name', () => { + /** + * Every insertable connector renders an always-focusable button, so a + * screen-reader user tabs through all of them. Naming them identically makes + * the set unusable; each must state the step it acts relative to. + */ + const slot = { id: 's-1', graphEdgeId: 'e1' }; + + it('names an ordinary gap by the step it follows', () => { + renderEdge({ kind: 'step', slot, insertAnchor: { kind: 'after', stepNumber: 3 } }); + expect(screen.getByRole('button', { name: 'Insert step after step 3' })).toBeInTheDocument(); + }); + + it('names a gap under an unnumbered source by the step it precedes', () => { + renderEdge({ kind: 'step', slot, insertAnchor: { kind: 'before', stepNumber: 1 } }); + expect(screen.getByRole('button', { name: 'Insert step before step 1' })).toBeInTheDocument(); + }); + + it('names a lane head by both the lane and its owning step', () => { + // Two `Then` lanes on different steps must not collide, so the owner's step + // number is part of the name rather than the lane label alone. + renderEdge({ + kind: 'branch-entry', + slot, + insertAnchor: { kind: 'branch', stepNumber: 2, branchLabel: 'Then' }, + }); + expect( + screen.getByRole('button', { name: 'Insert step into Then of step 2' }) + ).toBeInTheDocument(); + }); + + it('falls back to the bare label when neither endpoint is numbered', () => { + renderEdge({ kind: 'step', slot }); + expect(screen.getByRole('button', { name: 'Insert step' })).toBeInTheDocument(); + }); + }); + + 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..ee5e74233 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialConnectorEdge.tsx @@ -0,0 +1,333 @@ +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, + SequentialInsertAnchor, +} 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; +} + +/** + * Accessible name for a connector's insert (⊕) button. + * + * Every insertable connector renders one of these buttons, and they are real + * always-focusable ` + ); +} + +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..bc88566d2 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/useSequentialMoveMenuItems.test.tsx @@ -0,0 +1,118 @@ +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(), + ...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..451ad2285 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialMoveActions.test.ts @@ -0,0 +1,407 @@ +import { describe, expect, it } from 'vitest'; +import { + CONTAINER_CHAIN_NODE_IDS, + CROSS_CONTAINER_BRANCH_NODE_IDS, + MERGED_BRANCH_BODY_NODE_IDS, + makeContainerChainFixture, + makeCrossContainerBranchFixture, + makeDiamondFixture, + makeEmptyBranchFixture, + makeMergedBranchBodyFixture, + makeWireframeFixture, + WIREFRAME_NODE_IDS, +} from '../../utils/sequential/fixtures'; +import { projectSequence } from '../../utils/sequential/projectSequence'; +import { + appendSourceNodeId, + findIndentSlot, + 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('refuses an out-of-lane Move down under a bare branch owner, exactly as it refuses Outdent', () => { + // makeDiamondFixture: A -> If {true: B, false: C} -> D. B is the sole row of + // the Then lane, so BOTH "move down and out" and "move out" mean the same + // thing: after the If. The If is a bare branch owner with no forward + // continuation, so that seam has to be synthesized from its own source + // handle, which reads as a THIRD LANE. `outdent` was already gated on this; + // `down` was not, so Move down was a backdoor around the same guard (it + // additionally used to teleport B to the top level - see + // slotNavigation.test.ts). Both must now be disabled, together. + const { nodes, edges } = makeDiamondFixture(); + const projection = projectSequence(nodes, edges); + const options = computeSequentialMoveOptions(projection, 'b', (id) => + isForEachContainer(id, nodes) + ); + expect(options.down).toBeUndefined(); + expect(options.outdent).toBeUndefined(); + // Move up is NOT gated: it splices the OWNER'S INCOMING seam (A -> If), so + // the If only ever gains an incoming edge, never a competing lane. + expect(options.up?.graphEdgeId).toBe('a-if'); + }); + + it('keeps an in-lane Move down enabled even when the lane owner is a bare branch owner', () => { + // The "when (and only when)" half of the gate: the owner being a bare branch + // owner says nothing about a reorder BETWEEN two rows of its lane, which + // never touches the owner's handles. A -> If {true: B1 -> B2, false: C} -> D. + const nodes = [ + { id: 'a', type: 'uipath.script', position: { x: 0, y: 0 }, data: {} }, + { id: 'if', type: 'uipath.control-flow.decision', position: { x: 0, y: 100 }, data: {} }, + { id: 'b1', type: 'uipath.script', position: { x: 0, y: 200 }, data: {} }, + { id: 'b2', type: 'uipath.script', position: { x: 0, y: 300 }, data: {} }, + { id: 'c', type: 'uipath.script', position: { x: 0, y: 400 }, data: {} }, + { id: 'd', type: 'uipath.script', position: { x: 0, y: 500 }, data: {} }, + ]; + const edges = [ + { id: 'a-if', source: 'a', sourceHandle: 'output', target: 'if', targetHandle: 'input' }, + { id: 'if-b1', source: 'if', sourceHandle: 'true', target: 'b1', targetHandle: 'input' }, + { id: 'b1-b2', source: 'b1', sourceHandle: 'output', target: 'b2', targetHandle: 'input' }, + { id: 'b2-d', source: 'b2', sourceHandle: 'output', target: 'd', targetHandle: 'input' }, + { id: 'if-c', source: 'if', sourceHandle: 'false', target: 'c', targetHandle: 'input' }, + { id: 'c-d', source: 'c', sourceHandle: 'output', target: 'd', targetHandle: 'input' }, + ]; + const projection = projectSequence(nodes, edges); + const isContainerNode = (id: string) => isForEachContainer(id, nodes); + + // B1 and B2 are genuine lane siblings (depth 1, both owned by the If). + const b1 = computeSequentialMoveOptions(projection, 'b1', isContainerNode); + expect(b1.down?.graphEdgeId).toBe('b2-d'); // swap to the end of the lane + expect(b1.outdent).toBeUndefined(); // ...while leaving the lane is still refused + + const b2 = computeSequentialMoveOptions(projection, 'b2', isContainerNode); + expect(b2.up?.graphEdgeId).toBe('if-b1'); // swap to the head of the lane + expect(b2.down).toBeUndefined(); // bottom of the lane: out-of-lane, gated + }); + + it('allows Move down out of a REAL container body (the owner gate is bare-branch-only)', () => { + const { nodes, edges } = makeContainerChainFixture(); + const projection = projectSequence(nodes, edges); + const ids = CONTAINER_CHAIN_NODE_IDS; + const options = computeSequentialMoveOptions(projection, ids.y, (id) => + isForEachContainer(id, nodes) + ); + // Y is last in the body, so Move down leaves the lane - allowed here, + // because a container HAS a real forward seam (Container -> B) to splice. + expect(options.down?.graphEdgeId).toBe('chain-container-b'); + expect(options.down).toEqual(options.outdent); + }); + + it('offers Move up out of a branch lane while refusing Move down out of it', () => { + const { nodes, edges } = makeWireframeFixture(); + const projection = projectSequence(nodes, edges); + const options = computeSequentialMoveOptions(projection, WIREFRAME_NODE_IDS.thenJs, (id) => + isForEachContainer(id, nodes) + ); + // Up lands before the If, inside For Each's body. + expect(options.up?.graphEdgeId).toBe('e-foreach-if'); + expect(options.up?.containerId).toBe(WIREFRAME_NODE_IDS.forEach); + // Down would have to synthesize a seam from the If's own source handle. + expect(options.down).toBeUndefined(); + expect(options.outdent).toBeUndefined(); + }); + + it('disables Indent when the target body’s tail is a bare branch owner', () => { + // makeWireframeFixture: For Each's body ends with an `If` whose two lanes + // dead-end at the container boundary (the loop-continue idiom), so the body + // has no "after the If" position. `findIndentSlot` still resolves that tail + // and returns an append from the If's own source handle; committing it adds a + // THIRD LANE to the If rather than a step at the end of the body, so the view + // layer refuses it. (With a registry-supplied `getBranchHandles` the same + // edge WOULD be classified as the If's continuation, but only if the node + // actually has a continuation handle, which a bare branch owner does not - + // see computeSequentialMoveOptions' doc comment.) + const { nodes, edges } = makeWireframeFixture(); + const projection = projectSequence(nodes, edges); + + const raw = findIndentSlot(projection, WIREFRAME_NODE_IDS.sendMessage); + expect(raw?.source?.nodeId).toBe(WIREFRAME_NODE_IDS.ifNode); + expect(raw?.graphEdgeId).toBeUndefined(); // append shape, not a splice + + const options = computeSequentialMoveOptions(projection, WIREFRAME_NODE_IDS.sendMessage, (id) => + isForEachContainer(id, nodes) + ); + expect(options.indent).toBeUndefined(); + }); + + it('allows Indent when the body’s branch REJOINS, landing after the merge', () => { + // makeMergedBranchBodyFixture: Container [ X -> If -> {P, Q} -> M ] -> N. + // The tail is the merge M, a plain step with a real forward handle, so the + // append is sound and Indent stays enabled. This is the case the gate must + // NOT swallow. + const ids = MERGED_BRANCH_BODY_NODE_IDS; + const { nodes, edges } = makeMergedBranchBodyFixture(); + const projection = projectSequence(nodes, edges); + + const options = computeSequentialMoveOptions(projection, ids.after, (id) => + isForEachContainer(id, nodes) + ); + expect(options.indent?.source?.nodeId).toBe(ids.merge); + expect(options.indent?.containerId).toBe(ids.container); + }); + + it('keys the Indent gate on the slot SHAPE, so a splice off a branch owner stays allowed', () => { + // Only an append hands the source node a brand-new outgoing edge. A splice + // reuses an edge that already leaves it, which is why the empty-lane indent + // shape (the lane's own branch-entry edge, sourced at the branch owner) is + // sound and must not be caught by the gate. + const { nodes, edges } = makeEmptyBranchFixture(); + const projection = projectSequence(nodes, edges); + const laneEdgeSlot = projection.connectors.find( + (connector) => connector.kind === 'branch-entry' && connector.sourceRowId === 'if' + )?.slot; + expect(laneEdgeSlot?.source?.nodeId).toBe('if'); + expect(laneEdgeSlot?.graphEdgeId).toBeDefined(); + expect(appendSourceNodeId(laneEdgeSlot!)).toBeUndefined(); + + // ...versus the two shapes the predicate does and does not claim. + expect(appendSourceNodeId({ id: 's', source: { nodeId: 'n1' } })).toBe('n1'); + expect(appendSourceNodeId({ id: 's', target: { nodeId: 'n1' } })).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..b68cd4638 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialMoveActions.ts @@ -0,0 +1,230 @@ +import type { Edge, Node } from '@uipath/apollo-react/canvas/xyflow/react'; +import { DEFAULT_SOURCE_HANDLE_ID } from '../../constants'; +import { rowFor } from '../../utils/sequential/graph-helpers'; +import type { InsertionSlot, SequenceProjection } from '../../utils/sequential/sequential.types'; +import { + appendSourceNodeId, + 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')". None of `findMoveUpSlot`, + * `findMoveDownSlot` or `findIndentSlot` guarantees a source-only slot (each can + * return a splice or a prepend, both of which hand the owner a new outgoing + * edge), so all three are just as unsound as Outdent for these nodes. Move down + * is no exception even though such a node has no genuine forward step of its + * own: it now falls back to the out-of-lane path, which for the wireframe's `If` + * yields the `For Each -> Send Message` SPLICE and would give the `If` a third + * outgoing edge. 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 = rowFor(projection, 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. + * + * Three of the four directions share ONE refusal, implemented as the local + * `refuseIfItAppendsOntoALaneOwner` gate below: a slot that would hang a + * brand-new outgoing edge off a node whose source handles are its branch lanes + * is rejected, because such an edge reads as an extra lane rather than a next + * step. See the comment on that gate for why it is expressed against the SLOT + * rather than against the owner's identity, and why it is strictly safer than + * the three separate gates it replaces. + * + * `up` is ungated: "move up and out" splices the owner's INCOMING seam, which + * only ever adds an incoming edge to the owner, so it is sound even when the + * owner is a bare branch owner (see `findMoveUpAndOutSlot`). + * + * Worked example of what the gate costs, verified against the engine both ways + * using the wireframe with `send-message` re-parented under an `If.output` edge + * (the exact graph an Indent onto that tail would commit): projected WITHOUT + * `getBranchHandles`, `send-message` lands at depth 2 owned by the `If` as a + * third lane labelled "output"; projected WITH `getBranchHandles`, the same edge + * is classified as the If's continuation and `send-message` correctly lands at + * the body's level as its last step. So the registry path does make it sound, + * but only for a tail that genuinely HAS a continuation handle distinct from its + * declared branches - which is precisely what a bare branch owner lacks. The + * refusal therefore costs only the unmerged-branch-tail shape; a body ending in + * a plain step, or in a MERGED branch (`makeMergedBranchBodyFixture`), is + * unaffected. + */ +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 }; + } + // ONE rule for down/indent/outdent: refuse any slot that would hang a BRAND-NEW + // outgoing edge off a node whose source handles are its branch lanes, because + // that edge reads as an extra lane rather than a next step. + // + // This replaces three differently-shaped gates that all decided the same thing. + // The previous `outdent` gate asked about the OWNER's identity and the previous + // `down` gate re-derived, in this module, which internal branch + // `findMoveDownSlot` had taken (via `hasLaneSuccessor`) - two computations of + // one condition on opposite sides of a module boundary, with nothing keeping + // them in agreement. Asking `appendSourceNodeId` about the slot that was + // actually returned cannot drift: it inspects the value, not a reconstruction + // of how the value was produced. + // + // It is also correctly LESS conservative than the owner-identity test it + // replaces. An `If` that declares true/false AND a continuation output is a + // bare branch owner, yet `ownOutgoingConnector` finds its real forward seam, so + // `findOutdentSlot` returns a sound splice; the old gate discarded it anyway. + // The question was always about the SLOT, not the node. + // + // `up` needs no gate: `findMoveUpSlot` only ever returns a real edge slot or a + // `target`-only prepend, never an append, so this would be a no-op there. + const refuseIfItAppendsOntoALaneOwner = ( + slot: InsertionSlot | undefined + ): InsertionSlot | undefined => { + const appendsAfter = slot ? appendSourceNodeId(slot) : undefined; + return appendsAfter !== undefined && + isBareBranchOwner(projection, appendsAfter, isContainerNode) + ? undefined + : slot; + }; + + return { + up: findMoveUpSlot(projection, nodeId), + down: refuseIfItAppendsOntoALaneOwner(findMoveDownSlot(projection, nodeId)), + indent: refuseIfItAppendsOntoALaneOwner(findIndentSlot(projection, nodeId)), + outdent: refuseIfItAppendsOntoALaneOwner(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 = rowFor(projection, 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..fa9e64c0f --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/useCanvasViewViewport.ts @@ -0,0 +1,119 @@ +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 pan position for each presentation, even when the + * optional persisted SequentialViewProvider is absent. Zoom is intentionally NOT + * per-view: it carries across the toggle from whichever view the user is leaving. + * See the view-change effect below for the tradeoff that implies. + */ +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) { + // Deliberate: zoom is treated as a property of the user's current session, + // not of the view, so toggling restores the saved pan but never jumps the + // zoom level. Known consequence: the saved x/y were captured at a possibly + // different zoom, so the restored position can land off-target rather than + // exactly where the user left this view. If that tradeoff proves wrong, the + // fix is to restore `saved.zoom` instead of `departingViewport.zoom`. + void reactFlow.setViewport({ ...saved, zoom: departingViewport.zoom }, { duration: 300 }); + } else { + // Same intent on the first entry into a view: fit the new content, but clamp + // min/max zoom to the departing zoom so the toggle changes what is framed + // without changing how zoomed-in the user is. + 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..3782148b2 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/useSequentialGraph.test.ts @@ -0,0 +1,813 @@ +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 }, + }); + }); + + describe('insert anchors (accessible names for the ⊕ buttons)', () => { + /** + * The anchor is what stops every insert button on the canvas from announcing + * the same "Insert step". It must be present on exactly the connectors that + * render a button, and carry step numbers rather than geometry. + */ + it('pairs an anchor with every slot-bearing connector, and with nothing else', () => { + const { nodes, edges } = makeWireframeFixture(); + const graph = deriveSequentialGraph({ nodes, edges, view: 'sequential' }); + + expect(graph.edges.length).toBeGreaterThan(0); + for (const edge of graph.edges) { + // `slot` is what gates the button, so the anchor that names it must appear + // on precisely the same set of edges. + expect(edge.data?.insertAnchor !== undefined).toBe(edge.data?.slot !== undefined); + } + // Distinct positions produce distinct names: that is the whole point. + const names = graph.edges + .filter((edge) => edge.data?.insertAnchor) + .map((edge) => JSON.stringify(edge.data?.insertAnchor)); + expect(new Set(names).size).toBe(names.length); + }); + + it('anchors the start-bar gap on the step it precedes, since the start bar is unnumbered', () => { + const { nodes, edges } = makeWireframeFixture(); + const graph = deriveSequentialGraph({ nodes, edges, view: 'sequential' }); + + const startEdge = graph.edges.find((edge) => edge.source === SEQ_START_ROW_ID); + expect(startEdge?.data?.insertAnchor).toEqual({ kind: 'before', stepNumber: 1 }); + }); + + it('anchors a labeled lane head on both the lane and its owning step', () => { + const { nodes, edges } = makeWireframeFixture(); + const graph = deriveSequentialGraph({ nodes, edges, view: 'sequential' }); + + const laneEdge = graph.edges.find( + (edge) => edge.data?.kind === 'branch-entry' && edge.data?.slot && edge.data?.label + ); + expect(laneEdge?.data?.insertAnchor).toMatchObject({ + kind: 'branch', + branchLabel: laneEdge?.data?.label, + }); + expect((laneEdge?.data?.insertAnchor as { stepNumber: number }).stepNumber).toBeGreaterThan( + 0 + ); + }); + + it('drops the anchor with the slot on a connector hidden by a collapsed ancestor', () => { + const { nodes, edges } = makeWireframeFixture(); + const graph = deriveSequentialGraph({ + nodes, + edges, + view: 'sequential', + collapsedStepIds: new Set([WIREFRAME_NODE_IDS.forEach]), + }); + + const hidden = graph.edges.filter((edge) => edge.data?.slot === undefined); + expect(hidden.length).toBeGreaterThan(0); + for (const edge of hidden) { + expect(edge.data?.insertAnchor).toBeUndefined(); + } + }); + + it('keeps announced numbers stable when a container collapses (D7)', () => { + // The numbers come off the projection's pre-order counter, not from counting + // rendered rows, so folding a subtree must not renumber the gaps below it. + const { nodes, edges } = makeWireframeFixture(); + const anchorsFor = (collapsed?: Set) => + deriveSequentialGraph({ + nodes, + edges, + view: 'sequential', + ...(collapsed ? { collapsedStepIds: collapsed } : {}), + }) + .edges.filter((edge) => edge.data?.insertAnchor) + .map((edge) => [edge.id, JSON.stringify(edge.data?.insertAnchor)] as const); + + const collapsedAnchors = new Map(anchorsFor(new Set([WIREFRAME_NODE_IDS.forEach]))); + for (const [edgeId, anchor] of anchorsFor()) { + // Only compare gaps that survive the collapse; the folded ones are gone. + if (collapsedAnchors.has(edgeId)) { + expect(collapsedAnchors.get(edgeId)).toBe(anchor); + } + } + }); + }); + + 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('deriveSequentialGraph / useSequentialGraph projection parity', () => { + /** + * `deriveSequentialGraph` is what this file, the round-trip suite, and + * `SequentialCanvas.insertParity.test.ts` all assert against; `useSequentialGraph` + * is what production renders. The two used to hold independent copies of the + * "which nodes/edges enter the projection" chain, so the tested path and the + * running path could disagree while every test stayed green. They now share + * `projectSequenceForView`; this pins that down from the outside, so re-splitting + * them fails here rather than silently invalidating the suites above. + */ + const projectionCases = [ + { name: 'the wireframe', fixture: makeWireframeFixture }, + { name: 'deep nesting', fixture: makeDeepNestingFixture }, + { name: 'orphans', fixture: makeOrphanFixture }, + ] as const; + + for (const { name, fixture } of projectionCases) { + it(`projects ${name} identically through the pure function and the hook`, () => { + const { nodes, edges } = fixture(); + // Registry-shaped predicates, so the parity holds on the options the canvas + // actually passes down rather than only on the bare defaults. + const args = { + nodes, + edges, + view: 'sequential' as const, + isSequenceNode: (node: Node) => node.type !== 'stickyNote', + // The fixtures' real trigger type, so the parity covers the start-node + // exclusion rather than a predicate that happens to match nothing. + isStartNode: (node: Node) => node.type === 'uipath.first-run', + }; + + const pure = deriveSequentialGraph(args).projection; + const { result } = renderHook(() => useSequentialGraph(args)); + + expect(result.current.projection).toEqual(pure); + }); + } +}); + +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'); + }); + + // The central perf claim of the whole feature: a data-only edit (a rename + // keystroke replaces `node.data`, and therefore the `nodes` array identity) + // must NOT recompute the structural projection OR the geometry pass, because + // `sequenceFingerprint` is unchanged and the memo is reused. Asserted on the + // object references directly, since that is what the memo actually guarantees + // and what xyflow's reconciliation depends on. + it('reuses the projection and layout objects across a data-only change, but not a structural one', () => { + 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 firstProjection = result.current.projection; + const firstLayout = result.current.layout; + expect(firstProjection).not.toBeNull(); + expect(firstLayout).not.toBeNull(); + + // Exactly one node gets a fresh `data` object; ids, types, parentIds and the + // whole edge array are untouched, so the fingerprint string is identical. + const renamed = nodes.map((node) => + node.id === WIREFRAME_NODE_IDS.http + ? { ...node, data: { ...node.data, display: { label: 'Renamed' } } } + : node + ); + rerender({ nodes: renamed, edges }); + + expect(result.current.projection).toBe(firstProjection); + expect(result.current.layout).toBe(firstLayout); + // ...while the derived rows still pick up the new data. + const http = result.current.nodes.find((node) => node.id === WIREFRAME_NODE_IDS.http)!; + expect((http.data as { display: { label: string } }).display.label).toBe('Renamed'); + + // Negative control: a real structural change must re-project and re-lay out. + const extraEdge: Edge = { + id: 'extra-http-send', + source: WIREFRAME_NODE_IDS.http, + target: WIREFRAME_NODE_IDS.sendMessage, + }; + rerender({ nodes: renamed, edges: [...edges, extraEdge] }); + + expect(result.current.projection).not.toBe(firstProjection); + expect(result.current.layout).not.toBe(firstLayout); + }); + + 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..ed8a8678a --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/useSequentialGraph.ts @@ -0,0 +1,847 @@ +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, + SequenceConnector, + SequenceLayout, + SequenceProjection, +} from '../../utils/sequential/sequential.types'; +import { EMPTY_WAYPOINTS } from '../Edges/shared/constants'; +import type { + SequentialConnectorData, + SequentialInsertAnchor, +} 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; +} + +/** + * The projection half of the derivation: which nodes/edges enter the projection, + * and with which options. + * + * ONE definition, shared by {@link deriveSequentialGraph} (the pure entry point + * the round-trip and insert-parity tests drive) and {@link useSequentialGraph} + * (what production renders). The two used to hold independent copies of this + * filter chain, which meant the tested path and the running path could silently + * disagree about the projection they were asserting on -- the same class of + * unverified-equivalence gap `SequentialCanvas.insertParity.test.ts` exists to + * close for insert. They cannot drift now. + * + * Kept as a plain function taking the args object rather than a hook: the hook's + * call site is inside a `useMemo` keyed on the D12 structural fingerprints, not on + * these values' identities (see that memo's biome-ignore). + */ +function projectSequenceForView( + args: Pick< + UseSequentialGraphArgs, + | 'nodes' + | 'edges' + | 'isSequenceNode' + | 'isSequenceEdge' + | 'isStartNode' + | 'isContainerNode' + | 'resolveBranchLabel' + | 'getBranchHandles' + >, + collapsed: ReadonlySet +): SequenceProjection { + const sequenceNodes = args.isSequenceNode ? args.nodes.filter(args.isSequenceNode) : args.nodes; + const sequenceNodeIds = new Set(sequenceNodes.map((node) => node.id)); + const sequenceEdges = args.edges.filter( + (edge) => sequenceNodeIds.has(edge.source) && sequenceNodeIds.has(edge.target) + ); + return 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, + }); +} + +/** + * 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 / insert-parity tests + * exercise it without mounting xyflow or any provider. + * + * {@link useSequentialGraph} does NOT call it: the hook additionally stamps + * `node.ariaLabel` (needs the registry + lingui from React context) and reuses + * unchanged node objects across renders (needs a ref), neither of which belongs in + * a pure function. What the two DO share is {@link projectSequenceForView}, so the + * projection this function returns is the same projection production renders -- + * see that function's comment. + */ +export function deriveSequentialGraph( + args: UseSequentialGraphArgs +): SequentialGraph { + const { nodes } = args; + const collapsed = args.collapsedStepIds ?? EMPTY_COLLAPSED; + const projection = projectSequenceForView(args, collapsed); + 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) + ); + // Step numbers, for naming each insert affordance by its position in the + // sequence (see SequentialInsertAnchor). Read off the projection rather than + // counted here, so the announced number is the same stable pre-order number the + // gutter prints (D7) and does not shift under collapse. + const stepNumberByRowId = new Map(); + for (const row of projection.rows) { + if (row.stepNumber !== undefined) stepNumberByRowId.set(row.nodeId, row.stepNumber); + } + const insertAnchorFor = (connector: SequenceConnector): SequentialInsertAnchor | undefined => { + const sourceStep = stepNumberByRowId.get(connector.sourceRowId); + if (sourceStep !== undefined) { + // A labeled lane head is named by lane + owner, since the owner is the + // source here and several steps can each own a lane of the same name. + return connector.kind === 'branch-entry' && connector.label + ? { kind: 'branch', stepNumber: sourceStep, branchLabel: connector.label } + : { kind: 'after', stepNumber: sourceStep }; + } + // Unnumbered source (the synthetic start bar): anchor on the target instead. + const targetStep = stepNumberByRowId.get(connector.targetRowId); + return targetStep === undefined ? undefined : { kind: 'before', stepNumber: targetStep }; + }; + + 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); + // 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. + const renderedSlot = + isPreviewConnector || + hiddenRowIds.has(connector.targetRowId) || + hiddenRowIds.has(connector.sourceRowId) + ? undefined + : connector.slot; + const data: SequentialConnectorData = { + kind: connector.kind, + label: connector.label, + waypoints: layout.connectorWaypoints.get(connector.id) ?? EMPTY_WAYPOINTS, + slot: renderedSlot, + // Paired with `slot`: the button only renders when the slot does, so the + // anchor that names it is resolved on exactly the same condition. + insertAnchor: renderedSlot ? insertAnchorFor(connector) : undefined, + 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, + // The start bar is unnumbered, so this gap can only be named by the step + // it precedes ("before step 1"). + insertAnchor: + headSlot && firstTop?.stepNumber !== undefined + ? { kind: 'before', stepNumber: firstTop.stepNumber } + : undefined, + }, + ...(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. + // Deliberately written as the `\u0000` ESCAPE, never as a raw 0x00 + // byte: a literal NUL makes this whole file `data` rather than text, + // and grep then silently skips every match in it -- a nasty landmine + // in the one file that holds the D12 memo guards. The escape yields + // the identical runtime character, so the fingerprint string is + // byte-identical while the source stays plain text and greppable. + return `${edge.id}\u0000${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; + // Shared with deriveSequentialGraph so the tested and rendered projections + // cannot diverge; see projectSequenceForView. + return projectSequenceForView(args, collapsed); + }, [ + 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, _); + // Unlike the other render-phase ref writes in this feature (SequentialCanvas's + // `tailSlotRef`, useSequentialMoveActionsValue's `latest`), this one is NOT + // idempotent: it folds over render history, so a StrictMode double-invoke or a + // concurrent render React later discards can seed `previous` from a render that + // never committed. That is safe, and deliberately left as-is, because + // `reuseUnchangedSequentialNodes` is correctness-preserving by construction: it + // only ever substitutes `prior` for `node` when the two compare equal on every + // field except the deliberately-ignored ones, so a stale `previous` can cost at + // most some reference stability (an extra xyflow reconciliation), never a wrong + // rendered value. Do not "fix" this into an effect: the reuse has to happen + // DURING render, since its whole purpose is to hand xyflow reference-stable node + // objects in the same pass that produces them. + 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 + * `