From 536b7864905b8d6ebd3d206897db240af7bc812a Mon Sep 17 00:00:00 2001 From: snuziale <6452957+snuziale@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:47:48 -0700 Subject: [PATCH 1/5] feat(apollo-react): add sequential canvas view Introduce a sequential (list-style) canvas view for apollo-react as an alternative to the graph layout, with round-trip-safe view switching. - Projection and layout engine mapping the graph to ordered rows, with subtree-aware move operations and slot-based navigation - BaseNode bar render variant and sequential node shells aligned with card behavior for icons, selection, display, and a11y - Connector edges with smooth-step corners, shared branch spines, and dashed container merge-back routing - Insert pipeline with preview gap, shared cross-instance pending state, and container-aware parenting - Gutter, keyboard navigation, and aria labels - Virtualization past a node ceiling to avoid mount-burst loops, fitView on entry, and perf-loop fixes - Stories, i18n catalog, docs, and hardening tests Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/apollo-react/src/canvas/README.md | 55 ++ .../components/BaseCanvas/BaseCanvas.hooks.ts | 27 +- .../components/BaseCanvas/BaseCanvas.tsx | 3 +- .../components/BaseCanvas/BaseCanvas.types.ts | 12 + .../canvas/components/BaseNode/BaseNode.tsx | 126 ++- .../components/BaseNode/BaseNodeBar.test.tsx | 490 ++++++++++ .../components/BaseNode/BaseNodeBar.tsx | 390 ++++++++ .../Edges/shared/hooks/useEdgeGeometry.ts | 12 +- .../Edges/shared/primitives/EdgeLabel.tsx | 48 +- .../Edges/shared/primitives/index.ts | 4 +- .../SequentialAccessibleList.tsx | 72 ++ .../SequentialCanvas.roundtrip.test.ts | 129 +++ .../SequentialCanvas.stories.tsx | 460 +++++++++ .../SequentialCanvas/SequentialCanvas.tsx | 872 ++++++++++++++++++ .../SequentialCanvas.types.ts | 63 ++ .../SequentialCollapsedRowsContext.tsx | 43 + .../SequentialGutter.test.tsx | 254 +++++ .../SequentialCanvas/SequentialGutter.tsx | 165 ++++ .../SequentialInsertGapContext.tsx | 27 + .../SequentialInsertStateContext.tsx | 22 + .../SequentialMoveActionsContext.tsx | 35 + .../SequentialViewContext.tsx | 71 ++ .../SequentialCanvas/ToggleHarness.tsx | 91 ++ .../SequentialCanvas/ViewSwitcher.test.tsx | 15 + .../SequentialCanvas/ViewSwitcher.tsx | 44 + .../edges/SequentialBranchHeader.test.tsx | 15 + .../edges/SequentialBranchHeader.tsx | 37 + .../edges/SequentialConnectorEdge.stories.tsx | 218 +++++ .../edges/SequentialConnectorEdge.test.tsx | 79 ++ .../edges/SequentialConnectorEdge.tsx | 262 ++++++ .../edges/SequentialConnectorEdge.types.ts | 62 ++ .../edges/SequentialInsertButton.tsx | 57 ++ .../SequentialCanvas/edges/index.ts | 24 + .../edges/sequentialConnectorStyle.test.ts | 22 + .../edges/sequentialConnectorStyle.ts | 13 + .../edges/sequentialInsert.test.ts | 408 ++++++++ .../edges/sequentialInsert.ts | 293 ++++++ .../edges/useSequentialInsert.test.tsx | 106 +++ .../edges/useSequentialInsert.ts | 68 ++ .../components/SequentialCanvas/index.ts | 18 + .../nodes/BarVariant.stories.tsx | 123 +++ .../nodes/SequentialInsertPreviewNode.tsx | 75 ++ .../nodes/SequentialPlaceholderNode.tsx | 77 ++ .../nodes/SequentialStartNode.tsx | 77 ++ .../nodes/SequentialStepNode.test.tsx | 29 + .../nodes/SequentialStepNode.tsx | 52 ++ .../SequentialCanvas/nodes/index.ts | 40 + .../nodes/useSequentialMoveMenuItems.test.tsx | 119 +++ .../nodes/useSequentialMoveMenuItems.tsx | 75 ++ .../sequentialChangeFilters.test.ts | 262 ++++++ .../sequentialChangeFilters.ts | 246 +++++ .../sequentialGraph.constants.ts | 34 + .../sequentialMoveActions.test.ts | 261 ++++++ .../SequentialCanvas/sequentialMoveActions.ts | 178 ++++ .../synthesizePositionsForFlow.test.ts | 100 ++ .../synthesizePositionsForFlow.ts | 95 ++ .../SequentialCanvas/useCanvasViewMode.ts | 26 + .../useSequentialGraph.test.ts | 483 ++++++++++ .../SequentialCanvas/useSequentialGraph.ts | 649 +++++++++++++ .../useSequentialKeyboard.test.ts | 387 ++++++++ .../SequentialCanvas/useSequentialKeyboard.ts | 236 +++++ .../components/canvas-gallery.stories.tsx | 30 +- .../src/canvas/components/index.ts | 3 +- packages/apollo-react/src/canvas/constants.ts | 59 ++ .../src/canvas/hooks/usePreviewNode.ts | 21 +- .../apollo-react/src/canvas/locales/de.json | 25 +- .../apollo-react/src/canvas/locales/en.json | 26 +- .../src/canvas/locales/es-MX.json | 25 +- .../apollo-react/src/canvas/locales/es.json | 25 +- .../apollo-react/src/canvas/locales/fr.json | 25 +- .../apollo-react/src/canvas/locales/ja.json | 25 +- .../apollo-react/src/canvas/locales/ko.json | 25 +- .../src/canvas/locales/pt-BR.json | 25 +- .../apollo-react/src/canvas/locales/pt.json | 25 +- .../apollo-react/src/canvas/locales/ro.json | 25 +- .../apollo-react/src/canvas/locales/ru.json | 25 +- .../apollo-react/src/canvas/locales/tr.json | 25 +- .../src/canvas/locales/zh-CN.json | 25 +- .../src/canvas/locales/zh-TW.json | 25 +- .../SequentialCanvasStoryHarness.tsx | 104 +++ .../storybook-utils/sequential/index.ts | 7 + .../sequential/wireframeManifests.ts | 72 ++ .../apollo-react/src/canvas/utils/index.ts | 1 + .../utils/sequential/fingerprint.test.ts | 124 +++ .../canvas/utils/sequential/fingerprint.ts | 70 ++ .../src/canvas/utils/sequential/fixtures.ts | 420 +++++++++ .../canvas/utils/sequential/graph-helpers.ts | 169 ++++ .../src/canvas/utils/sequential/index.ts | 6 + .../utils/sequential/layoutSequence.test.ts | 254 +++++ .../canvas/utils/sequential/layoutSequence.ts | 156 ++++ .../canvas/utils/sequential/mergeAnalysis.ts | 91 ++ .../canvas/utils/sequential/mutations.test.ts | 535 +++++++++++ .../src/canvas/utils/sequential/mutations.ts | 559 +++++++++++ .../utils/sequential/previewRow.test.ts | 190 ++++ .../src/canvas/utils/sequential/previewRow.ts | 162 ++++ .../utils/sequential/projectSequence.test.ts | 496 ++++++++++ .../utils/sequential/projectSequence.ts | 521 +++++++++++ .../utils/sequential/sequential.types.ts | 181 ++++ .../utils/sequential/slotNavigation.test.ts | 204 ++++ .../canvas/utils/sequential/slotNavigation.ts | 243 +++++ .../apollo-react/src/test/canvas-mocks.ts | 2 + 101 files changed, 13803 insertions(+), 64 deletions(-) create mode 100644 packages/apollo-react/src/canvas/components/BaseNode/BaseNodeBar.test.tsx create mode 100644 packages/apollo-react/src/canvas/components/BaseNode/BaseNodeBar.tsx create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialAccessibleList.tsx create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCanvas.roundtrip.test.ts create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCanvas.stories.tsx create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCanvas.tsx create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCanvas.types.ts create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCollapsedRowsContext.tsx create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialGutter.test.tsx create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialGutter.tsx create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialInsertGapContext.tsx create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialInsertStateContext.tsx create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialMoveActionsContext.tsx create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialViewContext.tsx create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/ToggleHarness.tsx create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/ViewSwitcher.test.tsx create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/ViewSwitcher.tsx create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialBranchHeader.test.tsx create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialBranchHeader.tsx create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialConnectorEdge.stories.tsx create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialConnectorEdge.test.tsx create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialConnectorEdge.tsx create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialConnectorEdge.types.ts create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialInsertButton.tsx create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/edges/index.ts create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/edges/sequentialConnectorStyle.test.ts create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/edges/sequentialConnectorStyle.ts create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/edges/sequentialInsert.test.ts create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/edges/sequentialInsert.ts create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/edges/useSequentialInsert.test.tsx create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/edges/useSequentialInsert.ts create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/index.ts create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/BarVariant.stories.tsx create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialInsertPreviewNode.tsx create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialPlaceholderNode.tsx create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialStartNode.tsx create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialStepNode.test.tsx create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialStepNode.tsx create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/index.ts create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/useSequentialMoveMenuItems.test.tsx create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/useSequentialMoveMenuItems.tsx create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialChangeFilters.test.ts create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialChangeFilters.ts create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialGraph.constants.ts create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialMoveActions.test.ts create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialMoveActions.ts create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/synthesizePositionsForFlow.test.ts create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/synthesizePositionsForFlow.ts create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/useCanvasViewMode.ts create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/useSequentialGraph.test.ts create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/useSequentialGraph.ts create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/useSequentialKeyboard.test.ts create mode 100644 packages/apollo-react/src/canvas/components/SequentialCanvas/useSequentialKeyboard.ts create mode 100644 packages/apollo-react/src/canvas/storybook-utils/sequential/SequentialCanvasStoryHarness.tsx create mode 100644 packages/apollo-react/src/canvas/storybook-utils/sequential/index.ts create mode 100644 packages/apollo-react/src/canvas/storybook-utils/sequential/wireframeManifests.ts create mode 100644 packages/apollo-react/src/canvas/utils/sequential/fingerprint.test.ts create mode 100644 packages/apollo-react/src/canvas/utils/sequential/fingerprint.ts create mode 100644 packages/apollo-react/src/canvas/utils/sequential/fixtures.ts create mode 100644 packages/apollo-react/src/canvas/utils/sequential/graph-helpers.ts create mode 100644 packages/apollo-react/src/canvas/utils/sequential/index.ts create mode 100644 packages/apollo-react/src/canvas/utils/sequential/layoutSequence.test.ts create mode 100644 packages/apollo-react/src/canvas/utils/sequential/layoutSequence.ts create mode 100644 packages/apollo-react/src/canvas/utils/sequential/mergeAnalysis.ts create mode 100644 packages/apollo-react/src/canvas/utils/sequential/mutations.test.ts create mode 100644 packages/apollo-react/src/canvas/utils/sequential/mutations.ts create mode 100644 packages/apollo-react/src/canvas/utils/sequential/previewRow.test.ts create mode 100644 packages/apollo-react/src/canvas/utils/sequential/previewRow.ts create mode 100644 packages/apollo-react/src/canvas/utils/sequential/projectSequence.test.ts create mode 100644 packages/apollo-react/src/canvas/utils/sequential/projectSequence.ts create mode 100644 packages/apollo-react/src/canvas/utils/sequential/sequential.types.ts create mode 100644 packages/apollo-react/src/canvas/utils/sequential/slotNavigation.test.ts create mode 100644 packages/apollo-react/src/canvas/utils/sequential/slotNavigation.ts diff --git a/packages/apollo-react/src/canvas/README.md b/packages/apollo-react/src/canvas/README.md index 7395e635e..3db656c7e 100644 --- a/packages/apollo-react/src/canvas/README.md +++ b/packages/apollo-react/src/canvas/README.md @@ -195,3 +195,58 @@ 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. Flow-view positions (`node.position`) remain the persisted source of truth: `SequentialCanvas` derives a vertical layout from the graph's structure and renders that derived layout through `BaseCanvas`, but it never writes computed positions back onto the canonical nodes. Toggling from sequential back to flow is lossless because of this. The one exception is a node added while the sequential view is active: it has no meaningful flow position yet, so it is stamped `data.seqInserted` and given a real, non-overlapping position by `synthesizePositionsForFlow` the next time the canvas switches to flow view. + +### Minimal usage + +```tsx +import { + SequentialCanvas, + useCanvasViewMode, + ViewSwitcher, +} from '@uipath/apollo-react/canvas'; + +function MyFlow({ nodes, edges, onNodesChange, onEdgesChange }) { + const [view, setView] = useCanvasViewMode('my-flow.view'); + + return ( +
+ + +
+ ); +} +``` + +`SequentialCanvas` supplies its own `ReactFlowProvider` and keeps one `BaseCanvas` mounted while `view` changes. For a worked example, including per-view viewport save/restore, see `SequentialCanvasStoryHarness` and the `Wireframe` story. + +### Degraded graphs + +Not every graph is a clean, structured sequence. The projection degrades gracefully instead of failing: + +| Shape | Rendering | +|---|---| +| Multiple roots or disconnected components | Stacked lanes ordered by flow-view y | +| Unstructured merge (a node with more than one incoming edge) | Placed under its first incomer; the extra incoming edge draws a dashed goto connector | +| Cycles other than a loop's `loopBack` handle | A dashed, arrowless goto connector closes the cycle | +| Orphans (no sequence edges at all) | A de-emphasized trailing section after the terminal placeholder | + +Every case renders something rather than crashing or dropping a node: the cycle guard prevents infinite recursion, and unreachable or disconnected nodes are always appended as trailing rows. The toggle is never blocked. + +Both CSS delivery patterns described earlier in this guide (PostCSS scanning and precompiled Shadow DOM injection) cover the sequential view's markup with no extra configuration, since it is built entirely from Tailwind utility classes scanned from this package's `dist/canvas`. diff --git a/packages/apollo-react/src/canvas/components/BaseCanvas/BaseCanvas.hooks.ts b/packages/apollo-react/src/canvas/components/BaseCanvas/BaseCanvas.hooks.ts index dfeff5784..7291d7bf5 100644 --- a/packages/apollo-react/src/canvas/components/BaseCanvas/BaseCanvas.hooks.ts +++ b/packages/apollo-react/src/canvas/components/BaseCanvas/BaseCanvas.hooks.ts @@ -26,7 +26,13 @@ const waitForNodeMeasurements = (getNodes: () => Node[]): Promise => { export const useAutoLayout = ( nodes: Node[] | undefined, initialAutoLayout?: () => Promise | void, - fitViewOptions?: BaseCanvasFitViewOptions + fitViewOptions?: BaseCanvasFitViewOptions, + // When false, the layout + fitView runs ONCE (first ready node set) and never + // again — subsequent node-set changes (e.g. inserting/removing a node) do not + // re-fit. Canvases with a deterministic layout that must preserve the user's + // zoom/pan across edits opt out; the default preserves the re-fit-on-change + // behavior other canvases rely on. + refitOnNodeSetChange = true ) => { const [isReady, setIsReady] = useState(false); const hasRunLayout = useRef(false); @@ -46,7 +52,9 @@ export const useAutoLayout = ( const isNewNodeSet = currentNodeIds !== prevNodeIds.current && currentNodeIds?.length > 0; if (isNewNodeSet) { - hasRunLayout.current = false; + // Only re-arm the layout for the new set when re-fitting is enabled; when + // opted out we keep `hasRunLayout` sticky so the fit stays one-shot. + if (refitOnNodeSetChange) hasRunLayout.current = false; prevNodeIds.current = currentNodeIds; } @@ -59,8 +67,9 @@ export const useAutoLayout = ( return; } - // Already ran layout for this node set - if (hasRunLayout.current && !isNewNodeSet) { + // Already ran layout: skip when it's the same node set, or always when + // re-fitting on node-set change is opted out (one-shot fit). + if (hasRunLayout.current && (!isNewNodeSet || !refitOnNodeSetChange)) { return; } @@ -102,7 +111,15 @@ export const useAutoLayout = ( timeoutRef.current = null; } }; - }, [nodes?.length, reactFlow, initialAutoLayout, isNewNodeSet, fitViewOptions, isReady]); + }, [ + nodes?.length, + reactFlow, + initialAutoLayout, + isNewNodeSet, + fitViewOptions, + isReady, + refitOnNodeSetChange, + ]); return { isReady }; }; diff --git a/packages/apollo-react/src/canvas/components/BaseCanvas/BaseCanvas.tsx b/packages/apollo-react/src/canvas/components/BaseCanvas/BaseCanvas.tsx index 4deaca676..6f5a5dde8 100644 --- a/packages/apollo-react/src/canvas/components/BaseCanvas/BaseCanvas.tsx +++ b/packages/apollo-react/src/canvas/components/BaseCanvas/BaseCanvas.tsx @@ -93,6 +93,7 @@ const BaseCanvasInnerComponent = >(); - const { isReady } = useAutoLayout(nodes, initialAutoLayout, fitViewOptions); + const { isReady } = useAutoLayout(nodes, initialAutoLayout, fitViewOptions, refitOnNodeSetChange); const { ensureNodesInView, ensureAllNodesInView, centerNode } = useEnsureNodesInView(); // Maintain specified nodes in view when canvas resizes diff --git a/packages/apollo-react/src/canvas/components/BaseCanvas/BaseCanvas.types.ts b/packages/apollo-react/src/canvas/components/BaseCanvas/BaseCanvas.types.ts index a4f183277..47710729c 100644 --- a/packages/apollo-react/src/canvas/components/BaseCanvas/BaseCanvas.types.ts +++ b/packages/apollo-react/src/canvas/components/BaseCanvas/BaseCanvas.types.ts @@ -140,6 +140,18 @@ export interface BaseCanvasProps Promise | void; + /** + * Whether the post-mount `initialAutoLayout` + fitView should re-run each time + * the set of node ids changes (e.g. a node is added or removed). + * + * `true` (default) preserves the existing behavior: canvases that re-layout on + * data change re-fit to the new graph. Set `false` for canvases with a + * deterministic layout that must preserve the user's zoom/pan across edits, so + * the fit runs only once on first entry and inserting a node never re-zooms. + * @default true + */ + refitOnNodeSetChange?: boolean; + /** * Array of node IDs to keep in view when the canvas resizes. * The canvas will automatically pan (without changing zoom) to keep these nodes visible. diff --git a/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.tsx b/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.tsx index 56aa2c457..38385eb50 100644 --- a/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.tsx +++ b/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.tsx @@ -22,6 +22,8 @@ import { NODE_INNER_ICON_RATIO, NODE_INNER_RADIUS_RATIO, NODE_INNER_SHAPE_RATIO, + SEQ_BAR_HEIGHT, + SEQ_BAR_WIDTH, } from '../../constants'; import { useNodeTypeRegistry } from '../../core'; import { useElementValidationStatus, useNodeExecutionState } from '../../hooks'; @@ -40,6 +42,7 @@ import { useSelectionState } from '../BaseCanvas/SelectionStateContext'; import type { HandleActionEvent } from '../ButtonHandle/ButtonHandle'; import { SmartHandle, SmartHandleProvider } from '../ButtonHandle/SmartHandle'; import { useButtonHandles } from '../ButtonHandle/useButtonHandles'; +import type { NodeMenuItem } from '../NodeContextMenu'; import { InitialsBadge } from '../shared/InitialsBadge'; import { NodeToolbar } from '../Toolbar'; import type { @@ -49,6 +52,7 @@ import type { NodeStatusContext, } from './BaseNode.types'; import { BaseBadgeSlot } from './BaseNodeBadgeSlot'; +import { BaseNodeBar } from './BaseNodeBar'; import { useBaseNodeOverrideConfig } from './BaseNodeConfigContext'; import { BaseContainer } from './BaseNodeContainer'; import { BaseInnerShape } from './BaseNodeInnerShape'; @@ -81,8 +85,48 @@ const getIntrinsicHeight = ( return NODE_HEIGHT_DEFAULT; }; -const BaseNodeComponent = (props: NodeProps>) => { - const { type, data, selected, id, dragging, width, parentId } = props; +export type BaseNodeProps = NodeProps> & { + /** + * Visual variant, selected by the wrapping node component (never by wire + * schema or node data, per plan D2). 'card' (default) is the free-form canvas + * node; 'bar' is the horizontal Sequential Canvas row. The existing card / + * circle / rectangle code paths are reached only when this is 'card'. + */ + renderVariant?: 'card' | 'bar'; + /** + * Bar variant only: true when this Sequential Canvas row + * is a collapsed collapsible step. Passed straight through to BaseNodeBar's + * `stacked` prop; the wrapping SequentialStepNode reads it from view-local + * context (never node.data, keeping the clone reference-stable per D12). + * Ignored for `renderVariant="card"`. + */ + stacked?: boolean; + /** + * Bar variant only: extra kebab menu items appended after the + * resolved toolbar actions (D3 -- no new `BaseNodeOverrideConfig` field, + * just a direct passthrough prop), with a divider between the two groups + * when both are non-empty. `SequentialStepNode` computes these from the + * Sequential Canvas move-actions context (`useSequentialMoveMenuItems`). + * Ignored for `renderVariant="card"` (the card has no kebab of its own). + */ + extraMenuItems?: NodeMenuItem[]; +}; + +const BaseNodeComponent = (props: BaseNodeProps) => { + const { + type, + data, + selected, + id, + dragging, + width, + height, + parentId, + renderVariant = 'card', + stacked, + extraMenuItems, + } = props; + const isBar = renderVariant === 'bar'; // Read runtime configuration from context (provided by parent node components) const { @@ -222,6 +266,27 @@ const BaseNodeComponent = (props: NodeProps>) => { })); }, [handleConfigurationsProp, manifest, data, id]); + // Manifest handle ids, split by direction, for the bar variant to expose as + // invisible anchor handles (in addition to the generic seq-source/seq-target + // it renders for derived connectors). A bar collapses every side to one + // visual point, but the Add Node pipeline's connection validator resolves the + // existing node's handle by id against its manifest, so the sequential insert + // preview must anchor on a REAL manifest handle id (e.g. 'output'/'input', + // or a branch's 'true'/'false') that the bar actually renders -- otherwise the + // lookup misses and every candidate is filtered out. Only computed for bars. + const barManifestHandleIds = useMemo(() => { + if (!isBar) return { sources: [] as string[], targets: [] as string[] }; + const sources: string[] = []; + const targets: string[] = []; + for (const group of 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 }; + }, [isBar, handleConfigurations]); + // Toolbar config resolution with priority: props > manifest const toolbarConfig = useMemo(() => { // Priority 1: Prop override (runtime callbacks) @@ -251,6 +316,11 @@ const BaseNodeComponent = (props: NodeProps>) => { // 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. const computedHeight = useMemo(() => { + // Bars use the sequential layout-owned height and skip the left/right + // handle-lane floor (they carry only top/bottom handles, which add no floor). + if (isBar) { + return height ?? SEQ_BAR_HEIGHT; + } const leftHandles = handleConfigurations .filter((config) => config.position === Position.Left && config.visible !== false) .reduce( @@ -271,7 +341,7 @@ const BaseNodeComponent = (props: NodeProps>) => { const handleFloor = (leftRightHandles * 2 + 2) * GRID_SPACING; return Math.max(getIntrinsicHeight(!!footerComponent, footerVariant), handleFloor); - }, [handleConfigurations, footerComponent, footerVariant]); + }, [handleConfigurations, footerComponent, footerVariant, isBar, height]); // Write computedHeight to node.height and recalculate handle positions. Compare // against node.height (not the measured `height` prop) so a lagging measurement @@ -317,7 +387,11 @@ const BaseNodeComponent = (props: NodeProps>) => { // having per-component inline style objects. // --------------------------------------------------------------------------- const hasFooter = !!displayFooter; - const containerWidth = getContainerWidth(displayShape, width); + const containerWidth = isBar + ? width && width !== DEFAULT_NODE_SIZE && width !== DEFAULT_RECTANGLE_NODE_WIDTH + ? width + : SEQ_BAR_WIDTH + : getContainerWidth(displayShape, width); const containerHeight = computedHeight; const nodeVars = useMemo((): React.CSSProperties => { @@ -464,7 +538,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 +670,6 @@ const BaseNodeComponent = (props: NodeProps>) => { if (!manifest) { return ( - // biome-ignore lint/a11y/noStaticElementInteractions: canvas node interaction
>) => { ); } + // Sequential Canvas bar variant (D2/D3): same resolved display, adornments, + // and toolbar as the card, rendered as a horizontal row. Corner badge slots + // and the floating NodeToolbar are intentionally not rendered for bars; the + // trailing group inside BaseNodeBar carries the status indicator and kebab. + if (isBar) { + return ( + + ); + } + const nodeContent = ( - // biome-ignore lint/a11y/noStaticElementInteractions: canvas node interaction
({ + mockUpdateNode: vi.fn(), + mockGetNode: vi.fn(), + mockNodeState: { current: { height: undefined as number | undefined } }, + mockHandleConfigs: { current: undefined as unknown }, + mockManifest: { + current: { + display: { label: 'HTTP Request', shape: 'rectangle', icon: 'globe' }, + handleConfiguration: [], + } as Record, + }, + mockOverrideConfig: { current: {} as Record }, + mockMode: { current: 'design' as string }, + // biome-ignore lint/suspicious/noExplicitAny: captures NodeLabel props for assertions + mockNodeLabel: vi.fn() as any, + mockSelectionState: { current: { multipleNodesSelected: false } }, +})); + +mockGetNode.mockImplementation(() => mockNodeState.current); +mockUpdateNode.mockImplementation((_id: string, patch: { height?: number }) => { + if (patch?.height !== undefined) { + mockNodeState.current = { ...mockNodeState.current, height: patch.height }; + } +}); + +vi.mock('@uipath/apollo-react/canvas/xyflow/react', async (importOriginal) => ({ + ...(await importOriginal()), + // Stub Handle so the bar can render without a React Flow zustand provider. + Handle: () => null, + useStore: () => false, + useUpdateNodeInternals: () => vi.fn(), + useReactFlow: () => ({ + updateNodeData: vi.fn(), + updateNode: mockUpdateNode, + getNode: mockGetNode, + }), +})); + +vi.mock('../../hooks', () => ({ + useNodeExecutionState: () => undefined, + useElementValidationStatus: () => undefined, +})); +vi.mock('../../core', () => ({ + useNodeTypeRegistry: () => ({ getManifest: () => mockManifest.current }), +})); +vi.mock('../BaseCanvas/BaseCanvasModeProvider', () => ({ + useBaseCanvasMode: () => ({ mode: mockMode.current }), +})); +vi.mock('../BaseCanvas/ConnectedHandlesContext', () => ({ useConnectedHandles: () => new Set() })); +vi.mock('../BaseCanvas/SelectionStateContext', () => ({ + useSelectionState: () => mockSelectionState.current, +})); +vi.mock('../BaseCanvas/CanvasThemeContext', () => ({ + useCanvasTheme: () => ({ isDarkMode: false }), +})); +vi.mock('../Toolbar', () => ({ NodeToolbar: () => null })); +vi.mock('../ButtonHandle/useButtonHandles', () => ({ useButtonHandles: () => null })); +vi.mock('../ButtonHandle/SmartHandle', () => ({ + SmartHandle: () => null, + SmartHandleProvider: ({ children }: { children: React.ReactNode }) => <>{children}, +})); +vi.mock('./BaseNodeConfigContext', () => ({ + useBaseNodeOverrideConfig: () => ({ + handleConfigurations: mockHandleConfigs.current, + ...mockOverrideConfig.current, + }), +})); +// A resolved trailing status indicator, so the ActionNeeded precedence is testable. +vi.mock('../../utils/adornment-resolver', () => ({ + resolveAdornments: () => ({ topRight: }), +})); +vi.mock('../../utils/toolbar-resolver', () => ({ resolveToolbar: () => undefined })); +vi.mock('../../../i18n', () => ({ + useSafeLingui: () => ({ + _: (d: string | { message?: string; id: string }) => + typeof d === 'string' ? d : (d.message ?? d.id), + }), +})); +vi.mock('@uipath/apollo-wind', () => ({ + Skeleton: (props: Record) =>
, + Button: ({ children, ...props }: Record & { children?: React.ReactNode }) => ( + + ), + DropdownMenu: ({ children }: { children: React.ReactNode }) => <>{children}, + DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <>{children}, + DropdownMenuItem: ({ + children, + onSelect, + disabled, + }: { + children: React.ReactNode; + onSelect?: () => void; + disabled?: boolean; + }) => ( + + ), + DropdownMenuSeparator: () =>
, + cn: (...args: unknown[]) => + args + .flat(Infinity) + .filter((v): v is string => typeof v === 'string' && v.length > 0) + .join(' '), +})); +vi.mock('../../utils/icon-registry', () => ({ + getIcon: () => () =>
Icon
, + CanvasIcon: ({ icon }: { icon: string }) => , +})); +vi.mock('../../utils/manifest-resolver', () => ({ + resolveDisplay: (display: Record | undefined) => ({ + label: 'HTTP Request', + subLabel: 'GET /orders', + shape: 'rectangle', + icon: 'globe', + iconBackground: '#611f69', + color: '#ffffff', + ...display, + }), + resolveHandles: () => [], +})); +vi.mock('./NodeLabel', () => ({ + NodeLabel: (props: { label?: string }) => { + mockNodeLabel(props); + return
{props.label}
; + }, +})); +vi.mock('./BaseNodeInnerShape', () => ({ + BaseInnerShape: ({ + color, + background, + children, + }: { + color?: string; + background?: string; + children: React.ReactNode; + }) => ( +
+ {children} +
+ ), +})); + +import { SEQ_HANDLE_LEFT_OFFSET } from '../../constants'; +import { BaseNode } from './BaseNode'; +import { INVISIBLE_HANDLE_STYLE } from './BaseNodeBar'; + +const defaultProps: NodeProps> = { + id: 'step-1', + type: 'uipath.http', + data: {}, + selected: false, + dragging: false, + draggable: true, + zIndex: 0, + isConnectable: true, + positionAbsoluteX: 0, + positionAbsoluteY: 0, + selectable: true, + deletable: true, +}; + +describe('BaseNodeBar (sequential bar variant)', () => { + afterEach(() => { + mockUpdateNode.mockClear(); + mockNodeState.current = { height: undefined }; + mockHandleConfigs.current = undefined; + mockOverrideConfig.current = {}; + mockMode.current = 'design'; + mockNodeLabel.mockClear(); + mockSelectionState.current = { multipleNodesSelected: false }; + mockManifest.current = { + display: { label: 'HTTP Request', shape: 'rectangle', icon: 'globe' }, + handleConfiguration: [], + }; + }); + + // The core visual-drift contract: the same node resolves to the same display + // (label / icon / color source) whether it renders as a card or a bar. + describe('display parity with the card variant', () => { + it('renders the identical resolved label and icon', () => { + const { unmount } = render(); + const cardLabel = screen.getByTestId('node-label').textContent; + expect(screen.getAllByTestId('node-icon').length).toBeGreaterThan(0); + unmount(); + + render(); + expect(screen.getByTestId('node-label').textContent).toBe(cardLabel); + expect(screen.getAllByTestId('node-icon').length).toBeGreaterThan(0); + }); + + it('feeds the icon shape from the same color source', () => { + const { unmount } = render(); + const cardShape = screen.getByTestId('inner-shape'); + const cardBg = cardShape.getAttribute('data-background'); + const cardColor = cardShape.getAttribute('data-color'); + unmount(); + + render(); + const barShape = screen.getByTestId('inner-shape'); + expect(barShape.getAttribute('data-background')).toBe(cardBg); + expect(barShape.getAttribute('data-color')).toBe(cardColor); + expect(cardBg).toBe('#611f69'); + }); + }); + + describe('bar geometry', () => { + it('renders at the fixed 896x56 bar size regardless of handle count', () => { + render(); + const bar = screen.getByTestId('sequential-bar'); + expect(bar.style.getPropertyValue('--node-w')).toBe('896px'); + expect(bar.style.getPropertyValue('--node-h')).toBe('56px'); + }); + + it('writes the fixed bar height (56) back to node.height', () => { + render(); + expect(mockUpdateNode).toHaveBeenCalledWith('step-1', { height: 56 }); + }); + }); + + it('paints the left accent strip from the resolved icon background', () => { + render(); + const accent = screen.getByTestId('sequential-bar-accent'); + // Rendered as an inset box-shadow so the stripe follows the bar's rounded + // corners rather than a square-cornered strip. + expect(accent.style.boxShadow).toContain('#611f69'); + }); + + it('keeps inline rename enabled in design mode (readonly=false, onChange wired)', () => { + render(); + const labelProps = mockNodeLabel.mock.calls.at(-1)?.[0]; + expect(labelProps.readonly).toBe(false); + expect(typeof labelProps.onChange).toBe('function'); + expect(labelProps.shape).toBe('rectangle'); + }); + + it('disables rename outside design mode', () => { + mockMode.current = 'view'; + render(); + expect(mockNodeLabel.mock.calls.at(-1)?.[0].readonly).toBe(true); + }); + + describe('trailing group', () => { + it('renders the toolbar actions as a kebab when selected', () => { + mockOverrideConfig.current = { + toolbarConfig: { + actions: [{ id: 'delete', icon: , label: 'Delete', onAction: vi.fn() }], + }, + }; + render(); + expect(screen.getByLabelText('More options')).toBeInTheDocument(); + }); + + it('fires the toolbar action with the node id when the item is selected', () => { + const onAction = vi.fn(); + mockOverrideConfig.current = { + toolbarConfig: { + actions: [{ id: 'delete', icon: , label: 'Delete', onAction }], + }, + }; + render(); + fireEvent.click(screen.getByText('Delete')); + expect(onAction).toHaveBeenCalledWith('step-1'); + }); + + it('resolves a string toolbar icon through the icon registry instead of rendering it as literal text', () => { + mockOverrideConfig.current = { + toolbarConfig: { + actions: [{ id: 'delete', icon: 'trash', label: 'Delete', onAction: vi.fn() }], + }, + }; + render(); + expect(screen.getByTestId('canvas-icon-trash')).toBeInTheDocument(); + expect(screen.queryByText('trash')).not.toBeInTheDocument(); + }); + + it('still renders a custom React node icon as-is (no double resolution)', () => { + mockOverrideConfig.current = { + toolbarConfig: { + actions: [ + { + id: 'delete', + icon: , + label: 'Delete', + onAction: vi.fn(), + }, + ], + }, + }; + render(); + expect(screen.getByTestId('custom-icon')).toBeInTheDocument(); + }); + + it('hides the kebab when multiple nodes are selected, mirroring the card toolbar gating', () => { + mockOverrideConfig.current = { + toolbarConfig: { + actions: [{ id: 'delete', icon: , label: 'Delete', onAction: vi.fn() }], + }, + }; + mockSelectionState.current = { multipleNodesSelected: true }; + render(); + expect(screen.queryByLabelText('More options')).not.toBeInTheDocument(); + }); + + it('shows the status indicator when not action-needed', () => { + render(); + expect(screen.getByTestId('status-indicator')).toBeInTheDocument(); + expect(screen.queryByText('Action needed')).not.toBeInTheDocument(); + }); + + it('gives the Action needed pill precedence over the status indicator', () => { + const onActionNeeded = vi.fn(); + mockOverrideConfig.current = { executionStatusOverride: 'ActionNeeded', onActionNeeded }; + render(); + + expect(screen.queryByTestId('status-indicator')).not.toBeInTheDocument(); + const pill = screen.getByText('Action needed'); + fireEvent.click(pill); + expect(onActionNeeded).toHaveBeenCalledWith('step-1'); + }); + }); + + describe('extraMenuItems passthrough', () => { + it('shows the kebab from extraMenuItems alone, with no toolbar configured', () => { + render( + + ); + expect(screen.getByLabelText('More options')).toBeInTheDocument(); + expect(screen.getByText('Move up')).toBeInTheDocument(); + }); + + it('appends extraMenuItems after the toolbar actions with a divider between the two groups', () => { + mockOverrideConfig.current = { + toolbarConfig: { + actions: [{ id: 'delete', icon: , label: 'Delete', onAction: vi.fn() }], + }, + }; + const { container } = render( + + ); + expect(screen.getByText('Delete')).toBeInTheDocument(); + expect(screen.getByText('Move up')).toBeInTheDocument(); + // DropdownMenuSeparator is mocked to
(see the @uipath/apollo-wind mock above). + expect(container.querySelectorAll('hr')).toHaveLength(1); + }); + + it('fires an extraMenuItems action on click', () => { + const onClick = vi.fn(); + render( + + ); + fireEvent.click(screen.getByText('Move up')); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('renders a disabled extraMenuItems action as disabled', () => { + render( + + ); + expect(screen.getByText('Move up').closest('button')).toBeDisabled(); + }); + + it('renders no kebab at all when there is neither a toolbar nor extraMenuItems', () => { + render(); + expect(screen.queryByLabelText('More options')).not.toBeInTheDocument(); + }); + }); + + describe('display parity: background and shadow', () => { + it('applies display.background as an inline background style, like BaseContainer', () => { + mockManifest.current = { + display: { + label: 'HTTP Request', + shape: 'rectangle', + icon: 'globe', + background: 'linear-gradient(90deg, #611f69, #0ea5e9)', + }, + handleConfiguration: [], + }; + render(); + const bar = screen.getByTestId('sequential-bar'); + expect(bar.style.background).toContain('linear-gradient'); + }); + + it('renders the rest shadow class by default (display.shadow unset)', () => { + render(); + const bar = screen.getByTestId('sequential-bar'); + expect(bar.className).toContain('shadow-(--canvas-node-shadow-rest)'); + }); + + it('omits the rest shadow class when display.shadow is false, like BaseContainer', () => { + mockManifest.current = { + display: { label: 'HTTP Request', shape: 'rectangle', icon: 'globe', shadow: false }, + handleConfiguration: [], + }; + render(); + const bar = screen.getByTestId('sequential-bar'); + expect(bar.className).not.toContain('shadow-(--canvas-node-shadow-rest)'); + }); + }); + + describe('loading accessibility', () => { + it('sets aria-busy on the bar shell while loading, matching BaseContainer', () => { + render(); + const bar = screen.getByTestId('sequential-bar'); + expect(bar).toHaveAttribute('aria-busy', 'true'); + }); + + it('omits aria-busy when not loading', () => { + render(); + const bar = screen.getByTestId('sequential-bar'); + expect(bar).not.toHaveAttribute('aria-busy'); + }); + }); + + // Connectors anchor to the bar's bottom-left / top-left + // region instead of its horizontal center. `Handle` itself is stubbed to + // `null` above (BaseNode's Handle-rendering internals aren't under test + // here), so this asserts directly against the shared style object BOTH the + // top target and bottom source handles use -- the actual source of truth + // xyflow reads to position the real handle DOM nodes in production. + describe('handle anchoring', () => { + it('anchors the invisible top/bottom handles at SEQ_HANDLE_LEFT_OFFSET from the bar left edge, not the horizontal center', () => { + expect(INVISIBLE_HANDLE_STYLE.left).toBe(SEQ_HANDLE_LEFT_OFFSET); + }); + }); + + // A collapsed collapsible sequential row renders the same + // decorative "stacked" treatment BaseContainer applies to drillable/collapsed + // cards (a layered bar peeking out behind), driven by a `stacked` prop + // (threaded via context by SequentialStepNode, never node.data) rather than + // any state BaseNodeBar derives itself. + describe('collapsed stacked treatment', () => { + it('renders no stacked-layer classes or data-stacked attribute by default', () => { + render(); + const bar = screen.getByTestId('sequential-bar'); + expect(bar).not.toHaveAttribute('data-stacked'); + expect(bar.className).not.toContain('before:border-brand'); + }); + + it('renders the stacked-layer classes and data-stacked when stacked=true', () => { + render(); + const bar = screen.getByTestId('sequential-bar'); + expect(bar).toHaveAttribute('data-stacked', 'true'); + expect(bar.className).toContain('before:border-brand'); + expect(bar.className).toContain('before:-z-10'); + expect(bar.className).toContain('before:translate-y-[6px]'); + }); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/BaseNode/BaseNodeBar.tsx b/packages/apollo-react/src/canvas/components/BaseNode/BaseNodeBar.tsx new file mode 100644 index 000000000..1d4b84421 --- /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 + * `BaseNodeProps.extraMenuItems`'s doc comment (BaseNode.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 the card branch + * of BaseNode, so a node looks and behaves identically in both views. The card, + * circle, and rectangle code paths never reach here. + */ +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/Edges/shared/hooks/useEdgeGeometry.ts b/packages/apollo-react/src/canvas/components/Edges/shared/hooks/useEdgeGeometry.ts index 4c6b3c985..733c0978d 100644 --- a/packages/apollo-react/src/canvas/components/Edges/shared/hooks/useEdgeGeometry.ts +++ b/packages/apollo-react/src/canvas/components/Edges/shared/hooks/useEdgeGeometry.ts @@ -50,6 +50,13 @@ export type UseEdgeGeometryArgs = { * arrow polygon would have filled. */ hideArrowHead?: boolean; + /** + * Corner radius (px) for the rounded (smooth-step) waypoint path. Defaults to + * `EDGE_CONSTANTS.BORDER_RADIUS`. `createRoundedPath` clamps it to half the + * shorter adjacent segment, so an over-large value degrades gracefully on + * short segments. Only affects `waypoint` routing. + */ + borderRadius?: number; }; export type EdgeGeometry = { @@ -93,6 +100,7 @@ export function useEdgeGeometry(args: UseEdgeGeometryArgs): EdgeGeometry { autoRouted = false, enableSegments = true, hideArrowHead = false, + borderRadius = EDGE_CONSTANTS.BORDER_RADIUS, } = args; const arrowOffset = ARROW_OFFSETS[targetPosition]; @@ -132,8 +140,8 @@ export function useEdgeGeometry(args: UseEdgeGeometryArgs): EdgeGeometry { ); const waypointPath = useMemo( - () => createRoundedPath(pathPoints, EDGE_CONSTANTS.BORDER_RADIUS), - [pathPoints] + () => createRoundedPath(pathPoints, borderRadius), + [pathPoints, borderRadius] ); const handle = useEdgePath({ diff --git a/packages/apollo-react/src/canvas/components/Edges/shared/primitives/EdgeLabel.tsx b/packages/apollo-react/src/canvas/components/Edges/shared/primitives/EdgeLabel.tsx index 10bc89106..3a08ccce2 100644 --- a/packages/apollo-react/src/canvas/components/Edges/shared/primitives/EdgeLabel.tsx +++ b/packages/apollo-react/src/canvas/components/Edges/shared/primitives/EdgeLabel.tsx @@ -5,6 +5,34 @@ export type EdgeLabelProps = { selected?: boolean; }; +export type EdgeLabelContentProps = Pick & { + centered?: boolean; +}; + +/** Shared visual treatment for SVG and portal-positioned edge labels. */ +export function EdgeLabelContent({ text, selected, centered }: EdgeLabelContentProps) { + return ( +
+ {text} +
+ ); +} + export function EdgeLabel({ x, y, text, selected }: EdgeLabelProps) { return ( -
- {text} -
+
); } diff --git a/packages/apollo-react/src/canvas/components/Edges/shared/primitives/index.ts b/packages/apollo-react/src/canvas/components/Edges/shared/primitives/index.ts index ed4d258c1..0837be3f3 100644 --- a/packages/apollo-react/src/canvas/components/Edges/shared/primitives/index.ts +++ b/packages/apollo-react/src/canvas/components/Edges/shared/primitives/index.ts @@ -1,7 +1,7 @@ export type { EdgeArrowProps } from './EdgeArrow'; export { EdgeArrow } from './EdgeArrow'; -export type { EdgeLabelProps } from './EdgeLabel'; -export { EdgeLabel } from './EdgeLabel'; +export type { EdgeLabelContentProps, EdgeLabelProps } from './EdgeLabel'; +export { EdgeLabel, EdgeLabelContent } from './EdgeLabel'; export type { EdgePathProps } from './EdgePath'; export { EdgePath } from './EdgePath'; export type { SegmentDragHandleProps } from './SegmentDragHandle'; diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialAccessibleList.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialAccessibleList.tsx new file mode 100644 index 000000000..9f6fc22d3 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialAccessibleList.tsx @@ -0,0 +1,72 @@ +import type { Node } from '@uipath/apollo-react/canvas/xyflow/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; +} + +/** + * 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 = new Map(nodes.map((node) => [node.id, node.ariaLabel ?? node.id])); + + return ( +
    + {rows.map((row) => { + const label = labelsById.get(row.nodeId) ?? row.nodeId; + return ( +
  1. + + {row.collapsible && ( + + )} +
  2. + ); + })} +
+ ); +} diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCanvas.roundtrip.test.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCanvas.roundtrip.test.ts new file mode 100644 index 000000000..b3f9e23ad --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCanvas.roundtrip.test.ts @@ -0,0 +1,129 @@ +import type { Edge, Node } from '@uipath/apollo-react/canvas/xyflow/react'; +import { describe, expect, it } from 'vitest'; +import { sequenceFingerprint } from '../../utils/sequential/fingerprint'; +import { makeWireframeFixture, WIREFRAME_NODE_IDS } from '../../utils/sequential/fixtures'; +import { insertAtSlot } from '../../utils/sequential/mutations'; +import { projectSequence } from '../../utils/sequential/projectSequence'; +import { SEQ_INSERTED_FLAG } from './edges/sequentialInsert'; +import { synthesizePositionsForFlow } from './synthesizePositionsForFlow'; +import { deriveSequentialGraph } from './useSequentialGraph'; + +/** + * Acceptance test: insert a node while in the sequential view + * (simulated via the pure insertAtSlot op, exactly what the change-forwarding + * produces from the live pipeline), place it on toggle-to-flow with + * synthesizePositionsForFlow, and assert: + * 1. no inserted node overlaps another node in its frame; + * 2. NO pre-existing node moved (returned by identity, positions intact); + * 3. toggling back yields an identical projection (fingerprint + structure). + */ + +function applyChangeset( + nodes: Node[], + edges: Edge[], + changeset: ReturnType +): { nodes: Node[]; edges: Edge[] } { + const removeNodes = new Set(changeset.removeNodeIds); + const removeEdges = new Set(changeset.removeEdgeIds); + return { + nodes: [...nodes.filter((node) => !removeNodes.has(node.id)), ...changeset.addNodes], + edges: [...edges.filter((edge) => !removeEdges.has(edge.id)), ...changeset.addEdges], + }; +} + +function boxesOverlap(a: Node, b: Node): boolean { + const aw = a.width ?? 96; + const ah = a.height ?? 96; + const bw = b.width ?? 96; + const bh = b.height ?? 96; + return ( + a.position.x < b.position.x + bw && + a.position.x + aw > b.position.x && + a.position.y < b.position.y + bh && + a.position.y + ah > b.position.y + ); +} + +function assertNoOverlapWithinFrames(nodes: Node[]): void { + const byFrame = new Map(); + for (const node of nodes) { + const frame = node.parentId ?? '__root__'; + (byFrame.get(frame) ?? byFrame.set(frame, []).get(frame)!).push(node); + } + for (const frameNodes of byFrame.values()) { + for (let i = 0; i < frameNodes.length; i++) { + for (let j = i + 1; j < frameNodes.length; j++) { + expect(boxesOverlap(frameNodes[i]!, frameNodes[j]!)).toBe(false); + } + } + } +} + +function projectionSignature(nodes: Node[], edges: Edge[]) { + const projection = projectSequence(nodes, edges); + return { + rows: projection.rows.map((row) => ({ + nodeId: row.nodeId, + depth: row.depth, + stepNumber: row.stepNumber, + })), + connectors: projection.connectors.map((connector) => ({ + kind: connector.kind, + source: connector.sourceRowId, + target: connector.targetRowId, + })), + }; +} + +describe('sequential insert round-trip (D4)', () => { + it('inserts, places on toggle-to-flow without moving anything, and re-projects identically', () => { + const { nodes, edges } = makeWireframeFixture(); + + // 1. Derive the sequential view and grab the step slot between HTTP and JS. + const initial = deriveSequentialGraph({ nodes, edges, view: 'sequential' }); + const slot = initial.projection!.slots.find( + (candidate) => candidate.graphEdgeId === 'e-http-js' + ); + expect(slot).toBeDefined(); + + // 2. Simulate the pipeline insert: a new seqInserted node spliced into the slot. + const newNode: Node = { + id: 'inserted-1', + type: 'uipath.slack', + position: { x: 0, y: 0 }, + data: { display: { label: 'Send Slack' }, [SEQ_INSERTED_FLAG]: true }, + }; + const changeset = insertAtSlot(initial.projection!, slot!, newNode); + const afterInsert = applyChangeset(nodes, edges, changeset); + + // The insert is inline (HTTP -> inserted -> Javascript). + const insertedProjection = projectSequence(afterInsert.nodes, afterInsert.edges); + const insertedRow = insertedProjection.rows.find((row) => row.nodeId === 'inserted-1'); + expect(insertedRow?.depth).toBe(0); + + const fingerprintBefore = sequenceFingerprint(afterInsert.nodes, afterInsert.edges, new Set()); + const signatureBefore = projectionSignature(afterInsert.nodes, afterInsert.edges); + + // 3. Toggle to flow: synthesize a real position for the inserted node. + const flowNodes = synthesizePositionsForFlow(afterInsert.nodes, afterInsert.edges); + + // No overlaps anywhere. + assertNoOverlapWithinFrames(flowNodes); + + // No pre-existing node moved: each original node comes back by identity. + for (const id of Object.values(WIREFRAME_NODE_IDS)) { + const original = nodes.find((node) => node.id === id)!; + expect(flowNodes.find((node) => node.id === id)).toBe(original); + } + + // The inserted node now has a real position and no sequential marker. + const placed = flowNodes.find((node) => node.id === 'inserted-1')!; + expect((placed.data as Record)[SEQ_INSERTED_FLAG]).toBeUndefined(); + + // 4. Toggle back to sequential: identical projection (positions + the cleared + // flag are both ignored by the projection and the fingerprint). + const fingerprintAfter = sequenceFingerprint(flowNodes, afterInsert.edges, new Set()); + expect(fingerprintAfter).toBe(fingerprintBefore); + expect(projectionSignature(flowNodes, afterInsert.edges)).toEqual(signatureBefore); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCanvas.stories.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCanvas.stories.tsx new file mode 100644 index 000000000..c2858852f --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCanvas.stories.tsx @@ -0,0 +1,460 @@ +/** + * 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 { waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { Edge, Node } from '@uipath/apollo-react/canvas/xyflow/react'; +import { useEdgesState, useNodesState } from '@uipath/apollo-react/canvas/xyflow/react'; +import { ToggleGroup, ToggleGroupItem } from '@uipath/apollo-wind'; +import { useCallback, useMemo, useState } from 'react'; +import { SEQ_BAR_HEIGHT, SEQ_BAR_WIDTH, SEQ_INDENT_PX, SEQ_ROW_GAP } 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/SequentialCanvas/SequentialCanvas', + parameters: { layout: 'fullscreen' }, + decorators: [withCanvasProviders()], +}; + +export default meta; +type Story = StoryObj; + +const SEQUENCE_DENSITY_PRESETS = [ + { + id: 'compact', + label: 'Compact', + nodeWidth: 704, + nodeHeight: 48, + indentMultiplier: 0.75, + rowGap: 32, + }, + { + id: 'balanced', + label: 'Balanced', + nodeWidth: SEQ_BAR_WIDTH, + nodeHeight: SEQ_BAR_HEIGHT, + indentMultiplier: 1, + rowGap: SEQ_ROW_GAP, + }, + { + id: 'spacious', + label: 'Spacious', + nodeWidth: 1120, + nodeHeight: 72, + indentMultiplier: 1.5, + rowGap: 80, + }, +] as const; + +// --------------------------------------------------------------------------- +// (a) Wireframe: reproduces the design concept graph exactly. +// --------------------------------------------------------------------------- + +function WireframeStory() { + const fixture = useMemo(() => makeWireframeFixture(), []); + const [nodeWidth, setNodeWidth] = useState(SEQ_BAR_WIDTH); + const [nodeHeight, setNodeHeight] = useState(SEQ_BAR_HEIGHT); + const [indentMultiplier, setIndentMultiplier] = useState(1); + const [rowGap, setRowGap] = useState(SEQ_ROW_GAP); + 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')} + /> + +
+
+ Density preset + + {SEQUENCE_DENSITY_PRESETS.map((preset) => ( + + {preset.label} + + ))} + +
+ + + + +
+
+
+ ); +} + +export const Wireframe: Story = { + name: 'Wireframe', + 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 placeholder. Built from utils/sequential/fixtures.ts makeWireframeFixture, the same fixture the projection engine unit tests assert exact step numbers and connectors against.', + }, + }, + }, + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await userEvent.click(await canvas.findByRole('radio', { name: 'Compact' })); + await waitFor(() => { + const width = canvas.getByRole('slider', { name: 'Node width' }) as HTMLInputElement; + const height = canvas.getByRole('slider', { name: 'Node height' }) as HTMLInputElement; + const indent = canvas.getByRole('slider', { name: 'Indent multiplier' }) as HTMLInputElement; + const gap = canvas.getByRole('slider', { name: 'Gap between nodes' }) as HTMLInputElement; + if ( + width.value !== '704' || + height.value !== '48' || + indent.value !== '0.75' || + gap.value !== '32' + ) { + throw new Error('Expected the Compact preset to update all layout controls.'); + } + }); + + await userEvent.click(canvas.getByRole('radio', { name: 'Balanced' })); + + // For Each is step 3 (D7); its body (If=4, Javascript 1=5, HTTP Request 1=6) + // starts expanded. + await waitFor(() => { + if (!canvas.queryByText('If')) + throw new Error('Expected the If bar to be visible initially.'); + }); + + const collapseButton = await canvas.findByRole('button', { name: 'Collapse step 3' }); + await userEvent.click(collapseButton); + await waitFor(() => { + if (canvas.queryByText('If')) { + throw new Error('Expected collapsing For Each (step 3) to hide its body rows.'); + } + }); + + const expandButton = await canvas.findByRole('button', { name: 'Expand step 3' }); + await userEvent.click(expandButton); + await waitFor(() => { + if (!canvas.queryByText('If')) { + throw new Error('Expected expanding For Each (step 3) to restore its body rows.'); + } + }); + }, +}; + +// --------------------------------------------------------------------------- +// (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; + +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 PerformanceStory() { + const [unitCount, setUnitCount] = useState(DEFAULT_PERF_UNIT_COUNT); + const initialFixture = useMemo(() => createPerformanceFixture(DEFAULT_PERF_UNIT_COUNT), []); + const [nodes, setNodes, onNodesChange] = useNodesState(initialFixture.nodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(initialFixture.edges); + + const handleUnitCountChange = useCallback( + (event: React.ChangeEvent) => { + const nextCount = Number.parseInt(event.target.value, 10); + const nextFixture = createPerformanceFixture(nextCount); + setUnitCount(nextCount); + setNodes(nextFixture.nodes); + setEdges(nextFixture.edges); + }, + [setNodes, setEdges] + ); + + return ( + + +
+ Nodes + {nodes.length} +
+ +
+
+ ); +} + +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..a778d9556 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCanvas.tsx @@ -0,0 +1,872 @@ +import type { + Edge, + EdgeChange, + EdgeTypes, + Node, + NodeChange, + NodeTypes, + OnMove, + 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 { moveSubtree, 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 { 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 { + closesLoopToOwner, + computeSequentialMoveOptions, + getSequentialMoveSlot, + resolveSlotForCommit, + resolveTailInsertionSlot, + type SequentialMoveDirection, +} from './sequentialMoveActions'; +import { SEQ_SYNTHETIC_ROW_IDS, useSequentialGraph } from './useSequentialGraph'; +import { toggleCollapsedStepIds, useSequentialKeyboard } from './useSequentialKeyboard'; + +const EDGE_TYPES: EdgeTypes = { + [SEQ_CONNECTOR_EDGE_TYPE]: SequentialConnectorEdge as EdgeTypes[string], + // The Add Node preview pipeline stamps its transient edges `type: 'default'`. + // Render those through the same connector so the preview traces the real + // orthogonal rounded path (with an arrowhead) instead of xyflow's built-in + // bezier fallback, which is the only 'default' edge that occurs in this view. + default: SequentialConnectorEdge as EdgeTypes[string], +}; + +const EMPTY_POSITIONS: ReadonlyMap = new Map(); + +/** + * The Sequential Canvas view: an n8n/Zapier-style vertical projection of the + * same flow graph, rendered through the existing BaseCanvas (D1-D14). It renders + * either the canonical flow graph or its sequential projection on the same + * mounted BaseCanvas, selected by the orthogonal `view` prop (see ToggleHarness + * / SequentialViewProvider). Mutations flow out through the standard + * onNodesChange / onEdgesChange callbacks, with synthetic rows and view-only + * geometry filtered out (seam 2); canonical positions are never written back + * (D4). + * + * It supplies its own ReactFlowProvider (BaseCanvas requires one above it, + * BaseCanvas.tsx:116-122) so it is a self-contained drop-in, matching the + * MiniCanvasNavigator idiom rather than requiring the host to wrap it. + */ +export function SequentialCanvas( + props: SequentialCanvasProps +) { + return ( + + + + + + ); +} + +/** + * Owns insertion-gap state above every insertion hook in the canvas. Keeping + * the provider here is essential: terminal/lane/leaf handlers are created by + * SequentialCanvasInner itself, while connector handlers are rendered below + * BaseCanvas. Both must publish into the same state sink before the preview is + * opened. + */ +function SequentialCanvasInsertController( + props: SequentialCanvasProps +) { + const [activeInsertSlot, setActiveInsertSlot] = useState(undefined); + const { previewNode } = usePreviewNode(); + const wasPreviewOpenRef = useRef(false); + + useEffect(() => { + if (previewNode) { + wasPreviewOpenRef.current = true; + } else if (wasPreviewOpenRef.current) { + wasPreviewOpenRef.current = false; + setActiveInsertSlot(undefined); + } + }, [previewNode]); + + return ( + + + + ); +} + +function SequentialCanvasInner({ + nodes, + edges, + view = 'sequential', + sequenceLayoutOptions, + flowNodeTypes, + flowEdgeTypes, + onNodesChange, + onEdgesChange, + collapsedStepIds, + onCollapsedStepIdsChange, + onPrimaryAction, + onAddTrigger, + addNodeManagerProps, + canvasRef, + mode = 'view', + isDarkMode, + locale, + fitViewOptions, + onToolbarAction, + breakpoints, + children, + activeInsertSlot, + setActiveInsertSlot, +}: SequentialCanvasProps & { + activeInsertSlot?: InsertionSlot; + setActiveInsertSlot: Dispatch>; +}) { + const reactFlow = useReactFlow(); + const { startInsert, onBeforeNodeAdded } = useSequentialInsert(); + const seqView = useOptionalSequentialView(); + const registry = useOptionalNodeTypeRegistry(); + const isDesignMode = mode === 'design'; + // Canonical id->node map, reused everywhere a node lookup is needed (handle + // resolution, change forwarding, move commits). `childParentIds` is the set of + // ids that are some node's `parentId`, i.e. structural containers, so + // `isContainerNode` is an O(1) lookup instead of an O(n) scan per call. + const canonicalById = useMemo(() => new Map(nodes.map((node) => [node.id, node])), [nodes]); + const childParentIds = useMemo(() => { + const ids = new Set(); + for (const node of nodes) { + if (node.parentId) ids.add(node.parentId); + } + return ids; + }, [nodes]); + + const resolvedHandlesByNodeId = useMemo(() => { + const result = new Map>(); + for (const node of nodes) { + const manifest = node.type ? registry?.getManifest(node.type) : undefined; + if (!manifest) continue; + result.set( + node.id, + resolveHandles(manifest.handleConfiguration, { + ...(node.data as Record), + nodeId: node.id, + }) + ); + } + return result; + }, [nodes, registry]); + + const findResolvedHandle = useCallback( + (nodeId: string, handleId: string | null | undefined, type: 'source' | 'target') => { + const nodeType = canonicalById.get(nodeId)?.type; + const effectiveHandleId = + handleId ?? (nodeType ? registry?.getDefaultHandle(nodeType, type)?.id : undefined); + const groups = resolvedHandlesByNodeId.get(nodeId); + if (!groups) return undefined; + for (const group of groups) { + for (const handle of group.handles) { + if (handle.type === type && handle.id === effectiveHandleId) return handle; + } + } + return undefined; + }, + [resolvedHandlesByNodeId, canonicalById, registry] + ); + + const isSequenceEdge = useCallback( + (edge: E) => + findResolvedHandle(edge.source, edge.sourceHandle, 'source')?.handleType !== 'artifact' && + findResolvedHandle(edge.target, edge.targetHandle, 'target')?.handleType !== 'artifact', + [findResolvedHandle] + ); + + const isStartNode = useCallback( + (node: N) => + node.type === DEFAULT_TRIGGER_NODE_TYPE || + (node.type ? registry?.getManifest(node.type)?.category === 'trigger' : false), + [registry] + ); + + const isProjectedContainerNode = useCallback( + (node: N) => isContainerNodeManifest(node.type ? registry?.getManifest(node.type) : undefined), + [registry] + ); + + const resolveBranchLabel = useCallback( + (nodeId: string, handleId: string) => + findResolvedHandle(nodeId, handleId, 'source')?.label ?? handleId, + [findResolvedHandle] + ); + + // A parent node's resolved branch-lane handles (If → true/false, while → body, + // try/catch → try/catch/finally), so the projection can render every lane — + // empty ones as "+ Add step" placeholders — before any child edge exists. The + // continuation output is excluded when it uses a well-known `output` or + // `success` handle, or when it is the node's only source. `isDefaultForType` + // is intentionally not used as a branch discriminator: it describes default + // connection selection, not control-flow semantics. + const getBranchHandles = useCallback( + (node: N) => { + const candidates = (resolvedHandlesByNodeId.get(node.id) ?? []) + .flatMap((group) => group.handles) + .filter( + (handle) => handle.type === 'source' && handle.handleType !== 'artifact' && handle.visible + ); + const continuationId = + candidates.find((handle) => handle.id === 'output' || handle.id === 'success')?.id ?? + (candidates.length === 1 ? candidates[0]?.id : undefined); + return candidates + .filter((handle) => handle.id !== continuationId) + .map((handle) => ({ id: handle.id, label: resolveBranchLabel(node.id, handle.id) })); + }, + [resolvedHandlesByNodeId, resolveBranchLabel] + ); + + const collapsedSet = useMemo(() => new Set(collapsedStepIds ?? []), [collapsedStepIds]); + + // The slot whose Add Node panel is currently open, if any. Written by every + // useSequentialInsert() call site via SequentialInsertGapProvider, and cleared + // the moment the preview node closes -- covering BOTH cancel (the gap just + // closes) and commit (a real structural change relayouts right behind it; a + // same-frame flicker is acceptable). While set, useSequentialGraph re-runs ONLY + // its cheap layout pass with a preview row spliced in at the slot (the + // projection memo stays fingerprint-keyed, D12), so the gap opens exactly one + // row, the preview bar seats in its slot, and every connector re-routes from + // the shifted geometry. + // Terminal "Add step" placeholder: append after the last top-level row. Read + // from a ref so the callback identity stays stable (it feeds the synthetic + // placeholder's data, which must not churn every render). + const tailSlotRef = useRef(undefined); + const onPlaceholderAdd = useCallback(() => { + if (!isDesignMode || view !== 'sequential') return; + const slot = tailSlotRef.current; + if (!slot?.source) return; + const placeholder = reactFlow.getNode(SEQ_PLACEHOLDER_ROW_ID); + startInsert({ + slot, + source: slot.source.nodeId, + sourceHandleId: slot.source.handleId ?? DEFAULT_SOURCE_HANDLE_ID, + target: '', + targetHandleId: undefined, + sourcePosition: Position.Bottom, + position: placeholder?.position ?? { x: 0, y: 0 }, + }); + }, [isDesignMode, view, reactFlow, startInsert]); + + // An empty branch lane's "+ Add step" placeholder: append the first node into + // that lane via the carried slot (source = parent, sourceHandle = the branch + // handle, containerId set so it parents correctly). The preview then re-seats + // in the lane via the layout swap (projectionWithPreviewRow). + const onLaneAdd = useCallback( + (slot: InsertionSlot) => { + if (!isDesignMode || view !== 'sequential' || !slot.source) return; + const sourceNode = canonicalById.get(slot.source.nodeId); + const resolvedSource = { + ...slot.source, + handleId: + slot.source.handleId ?? + (sourceNode?.type + ? registry?.getDefaultHandle(sourceNode.type, 'source')?.id + : undefined) ?? + DEFAULT_SOURCE_HANDLE_ID, + }; + const resolvedSlot: InsertionSlot = { + ...slot, + source: resolvedSource, + }; + const placeholder = reactFlow + .getNodes() + .find( + (node) => + (node.data as { insertionSlotId?: string } | undefined)?.insertionSlotId === slot.id + ); + startInsert({ + slot: resolvedSlot, + source: resolvedSource.nodeId, + sourceHandleId: undefined, + target: '', + targetHandleId: undefined, + sourcePosition: Position.Bottom, + position: placeholder?.position ?? { x: 0, y: 0 }, + }); + }, + [isDesignMode, view, canonicalById, registry, reactFlow, startInsert] + ); + + const { + nodes: seqNodesRaw, + edges: seqEdges, + projection, + layout, + } = useSequentialGraph({ + nodes, + edges, + view, + collapsedStepIds: collapsedSet, + onAddTrigger: isDesignMode && view === 'sequential' ? onAddTrigger : undefined, + onPlaceholderAdd: isDesignMode && view === 'sequential' ? onPlaceholderAdd : undefined, + isSequenceEdge, + isStartNode, + isContainerNode: isProjectedContainerNode, + resolveBranchLabel, + getBranchHandles, + onLaneAdd: isDesignMode && view === 'sequential' ? onLaneAdd : undefined, + insertSlot: view === 'sequential' ? activeInsertSlot : undefined, + layoutOptions: sequenceLayoutOptions, + }); + + const tailSlot = useMemo(() => { + return resolveTailInsertionSlot( + projection, + nodes, + (nodeType) => registry?.getDefaultHandle(nodeType, 'source')?.id, + isStartNode + ); + }, [projection, nodes, registry, isStartNode]); + tailSlotRef.current = tailSlot; + + // The Add Node pipeline (showPreviewGraph) adds its `preview` node + preview + // edges via `instance.setNodes` / `instance.setEdges`. This canvas is + // CONTROLLED (nodes/edges come from the derived arrays), and in that mode + // xyflow routes them through onNodesChange / onEdgesChange as `add` changes + // rather than writing the store; the handlers below capture the preview into + // this local state and we merge it into the controlled arrays here (it never + // reaches canonical state -- the change filters drop PREVIEW_NODE_ID / + // isPreviewEdge). The preview node's position is overridden with the slot the + // layout opened (layout.positions[PREVIEW_NODE_ID]), so the ghost bar sits + // centered in the gap instead of at the click-time midpoint. + const [insertPreviewNode, setInsertPreviewNode] = useState(null); + const [insertPreviewEdges, setInsertPreviewEdges] = useState([]); + const seqNodes = useMemo(() => { + // A lane placeholder loses its layout position when its lane is being + // inserted into (projectionWithPreviewRow swaps it for the preview row), so + // drop the now-orphaned placeholder; every other lane placeholder still has a + // position and renders normally. + const base = seqNodesRaw.filter( + (node) => !node.id.startsWith(SEQ_LANE_PLACEHOLDER_PREFIX) || !!layout?.positions.has(node.id) + ); + if (view !== 'sequential' || !insertPreviewNode) return base; + const slotPosition = layout?.positions.get(PREVIEW_NODE_ID); + const positionedPreview = slotPosition + ? { + ...insertPreviewNode, + position: slotPosition, + width: sequenceLayoutOptions?.barWidth ?? insertPreviewNode.width, + height: sequenceLayoutOptions?.barHeight ?? insertPreviewNode.height, + } + : { + ...insertPreviewNode, + width: sequenceLayoutOptions?.barWidth ?? insertPreviewNode.width, + height: sequenceLayoutOptions?.barHeight ?? insertPreviewNode.height, + }; + return [...base, positionedPreview]; + }, [ + view, + seqNodesRaw, + insertPreviewNode, + layout, + sequenceLayoutOptions?.barWidth, + sequenceLayoutOptions?.barHeight, + ]); + const seqEdgesWithPreview = useMemo( + () => + // While a slot is active, useSequentialGraph has already replaced the + // affected connector with routed preview connectors. The raw edges from + // showPreviewGraph remain useful to AddNodeManager's store bookkeeping, + // but rendering them too would duplicate the path and use click-time + // geometry that jumps when the real node is committed. + view === 'sequential' && activeInsertSlot + ? seqEdges + : view === 'sequential' && insertPreviewEdges.length > 0 + ? [...seqEdges, ...insertPreviewEdges] + : seqEdges, + [view, activeInsertSlot, seqEdges, insertPreviewEdges] + ); + useEffect(() => { + if (view !== 'flow') return; + setInsertPreviewNode(null); + setInsertPreviewEdges([]); + setActiveInsertSlot(undefined); + }, [view, setActiveInsertSlot]); + + // Every real manifest type maps to the bar step node; synthetic rows use their + // own components. Memoized on the set of types present so it is reference-stable + // across data/position changes. + const nodeTypeKey = useMemo( + () => [...new Set(nodes.map((node) => node.type ?? 'default'))].sort().join('|'), + [nodes] + ); + const sequentialNodeTypes = useMemo(() => { + const types: NodeTypes = { ...SEQUENTIAL_SYNTHETIC_NODE_TYPES }; + for (const key of nodeTypeKey.split('|')) { + if (key && !types[key]) types[key] = SequentialStepNode; + } + types.default ??= SequentialStepNode; + // The Add Node pipeline (showPreviewGraph) drops a `preview`-typed node into + // the store while the panel is open; without this it cannot render (D2). The + // sequential view uses a bar-shaped ghost (icon left + "New step") instead of + // the flow-view square AddNodePreview, which reads as an empty slab at the + // bar's 16:1 aspect ratio. + types.preview ??= SequentialInsertPreviewNode; + return types; + }, [nodeTypeKey]); + const resolvedFlowNodeTypes = useMemo(() => { + if (flowNodeTypes) return flowNodeTypes; + const types: NodeTypes = {}; + for (const key of nodeTypeKey.split('|')) { + if (key) types[key] = BaseNode; + } + types.default ??= BaseNode; + return types; + }, [flowNodeTypes, nodeTypeKey]); + const nodeTypes = view === 'sequential' ? sequentialNodeTypes : resolvedFlowNodeTypes; + const edgeTypes = view === 'sequential' ? EDGE_TYPES : flowEdgeTypes; + + // 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 getMoveOptions = useCallback( + (nodeId: string) => { + if (!projection) + return { up: undefined, down: undefined, indent: undefined, outdent: undefined }; + const options = computeSequentialMoveOptions(projection, nodeId, isContainerNode); + if (options.outdent && closesLoopToOwner(projection, nodeId, edges)) { + return { ...options, outdent: undefined }; + } + return options; + }, + [projection, isContainerNode, edges] + ); + + const getDefaultSourceHandleId = useCallback( + (nodeType: string) => registry?.getDefaultHandle(nodeType, 'source')?.id, + [registry] + ); + + // Applies moveSubtree's GraphChangeSet through the canonical onNodesChange / + // onEdgesChange props DIRECTLY (D10: the public API stays those two + // callbacks; this is NOT routed through handleNodesChange/handleEdgesChange + // above, because those exist to strip view-only geometry off changes the + // DERIVED view's own xyflow instance emits -- a move's GraphChangeSet is + // built straight from the CANONICAL {nodes, edges} via moveSubtree, so it is + // already canonical-shaped and carries a real parentId rewrite that the + // clone-stripping `replace` translation would otherwise drop). + const commitMove = useCallback( + (nodeId: string, slot: InsertionSlot) => { + if (!projection) return; + const resolvedSlot = resolveSlotForCommit(slot, canonicalById, getDefaultSourceHandleId); + const changeSet = moveSubtree(projection, nodeId, resolvedSlot, { nodes, edges }); + if ( + changeSet.addNodes.length === 0 && + changeSet.addEdges.length === 0 && + changeSet.removeNodeIds.length === 0 && + changeSet.removeEdgeIds.length === 0 + ) { + return; + } + onNodesChange?.(graphChangeSetToNodeChanges(changeSet)); + onEdgesChange?.(graphChangeSetToEdgeChanges(changeSet)); + }, + [ + projection, + canonicalById, + getDefaultSourceHandleId, + nodes, + edges, + onNodesChange, + onEdgesChange, + ] + ); + + const deleteStep = useCallback( + (nodeId: string) => { + if (!isDesignMode || view !== 'sequential' || !projection) return; + const changeSet = removeStep(projection, nodeId, { nodes, edges }); + onNodesChange?.(graphChangeSetToNodeChanges(changeSet)); + onEdgesChange?.(graphChangeSetToEdgeChanges(changeSet)); + }, + [isDesignMode, view, projection, nodes, edges, onNodesChange, onEdgesChange] + ); + + const handleToolbarAction = useCallback< + NonNullable['onToolbarAction']> + >( + (event) => { + if (view === 'sequential' && event.actionId === 'delete') { + deleteStep(event.nodeId); + return; + } + onToolbarAction?.(event); + }, + [view, deleteStep, onToolbarAction] + ); + + // Reference-chip navigation (D9 density fallback) shares this same binding + // (team guidance: thread it "via the same context as the move actions"). + // Mirrors BaseCanvas's own centerNode (BaseCanvas.hooks.ts) directly against + // the raw ReactFlow instance already held above, since that instance -- not + // the imperative BaseCanvasRef, which is only for external consumers -- is + // what this component itself has in scope. + const centerOnNode = useCallback( + (nodeId: string) => { + const node = reactFlow.getInternalNode(nodeId); + if (!node) return; + const width = node.measured?.width ?? node.width; + const height = node.measured?.height ?? node.height; + if (!width || !height) return; + reactFlow.setCenter(node.position.x + width / 2, node.position.y + height / 2, { + zoom: reactFlow.getViewport().zoom, + duration: 300, + }); + }, + [reactFlow] + ); + + const moveActionsValue = useMemo( + () => ({ + getMoveOptions, + commitMove, + centerOnNode, + }), + [getMoveOptions, commitMove, centerOnNode] + ); + + // 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(getMoveOptions(nodeId), direction); + if (slot) commitMove(nodeId, slot); + }, + [isDesignMode, getMoveOptions, commitMove] + ); + + // 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): track the sequential viewport into the + // optional context and restore it on mount, following the HierarchicalCanvas + // save/restore precedent applied to the view axis. + const handleMove = useCallback( + (_event, viewport) => seqView?.saveViewport(view, viewport), + [seqView, view] + ); + const defaultViewport = seqView?.getViewport(view); + + const previousViewRef = useRef(view); + useEffect(() => { + if (previousViewRef.current === view) return; + previousViewRef.current = view; + const saved = seqView?.getViewport(view); + if (saved) { + void reactFlow.setViewport(saved, { duration: 300 }); + } else { + void reactFlow.fitView({ ...fitViewOptions, duration: 300 }); + } + }, [view, seqView, reactFlow, fitViewOptions]); + + // fitView on first entry (D3): BaseCanvas only runs its post-mount fitView + // when an `initialAutoLayout` is provided; without one the viewport stays at + // the raw {0,0,1} origin and the gutter, the Workflow start bar, and any + // tall graph's trailing rows render off-screen. Sequential positions are + // already deterministic (layoutSequence), so no real layout work is needed: + // a stable no-op is enough to trigger the fit and fade-in. Skip it only when + // this instance mounted with a saved viewport to restore (returning to a + // previously-panned sequential view), so the fit does not clobber the + // restored position. Captured once at mount so a later saveViewport does not + // flip the behavior mid-session. + const hadSavedViewportAtMount = useRef(defaultViewport !== undefined); + const noopInitialLayout = useCallback(() => {}, []); + const initialAutoLayout = + view === 'sequential' && !hadSavedViewportAtMount.current ? noopInitialLayout : undefined; + + // Render every row into the DOM for reading-order a11y (D8), but only up to a + // ceiling: past it, re-enable xyflow's viewport virtualization so a mount + // burst of many hundred fixed bars can't drive xyflow's ResizeObserver / + // updateNodeInternals cycle past React's nested-update limit. See + // SEQ_FULL_RENDER_MAX_NODES for the tradeoff. + const virtualizeForScale = view === 'sequential' && seqNodes.length > SEQ_FULL_RENDER_MAX_NODES; + + return ( + // Keyboard nav (D8) is attached to this wrapper div rather than `document` + // (see useSequentialKeyboard's doc): it only reacts once focus/bubbling + // genuinely lands inside this canvas, so multiple canvases on one page + // never steal each other's key events. + // + // SequentialCollapsedRowsProvider wraps the whole subtree so every + // SequentialStepNode xyflow mounts underneath BaseCanvas can read the + // collapsed row-id set and render the "stacked" treatment without the + // collapse toggle ever touching node.data (D12). + + +
+ + ref={canvasRef} + nodes={seqNodes} + edges={seqEdgesWithPreview} + nodeTypes={nodeTypes} + edgeTypes={edgeTypes} + mode={mode} + isDarkMode={isDarkMode} + locale={locale} + fitViewOptions={fitViewOptions} + onToolbarAction={handleToolbarAction} + breakpoints={breakpoints} + // Accessibility: keep every row in the DOM so reading order == row + // order (D8), except past SEQ_FULL_RENDER_MAX_NODES where + // virtualization is re-enabled for stability at scale. + onlyRenderVisibleElements={virtualizeForScale} + aria-hidden={virtualizeForScale || undefined} + // Sequential bars are not user-connectable in v1. + nodesConnectable={view === 'flow'} + onNodesChange={handleNodesChange} + onEdgesChange={handleEdgesChange} + onMove={handleMove} + defaultViewport={defaultViewport} + initialAutoLayout={initialAutoLayout} + // Sequential layout is deterministic (layoutSequence) and the + // initial fit is one-shot: never re-fit when a node is inserted or + // removed, so an edit never quietly zooms the canvas out (D3). + refitOnNodeSetChange={view !== 'sequential'} + > + {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..79f11df7c --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCanvas.types.ts @@ -0,0 +1,63 @@ +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; + /** Node/edge registrations used while `view="flow"`. */ + flowNodeTypes?: NodeTypes; + 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..07ae7fce2 --- /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 } 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 function SequentialGutter({ + rows, + positions, + barHeight = SEQ_BAR_HEIGHT, + collapsedStepIds, + onToggleCollapse, +}: SequentialGutterProps) { + const { _ } = useSafeLingui(); + + if (rows.length === 0) return null; + + // The stack's own left edge: `layoutSequence` always anchors depth-0 rows at + // x = 0 (utils/sequential/layoutSequence.ts, read-only reference), but this + // reads it from the actual positions rather than assuming it, so a future + // layout change (e.g. a reference chip rendered further left) can't silently + // desync the rail from the bars it labels. + let stackLeftX = 0; + for (const position of positions.values()) { + if (position.x < stackLeftX) stackLeftX = position.x; + } + const columnLeftX = stackLeftX - SEQ_GUTTER_COLUMN_WIDTH_PX - SEQ_GUTTER_LEADER_GAP_PX; + const leaderStartX = stackLeftX - SEQ_GUTTER_LEADER_GAP_PX; + + return ( + +
+ {rows.map((row) => { + const position = positions.get(row.nodeId); + if (!position || row.stepNumber === undefined) return null; + const collapsed = collapsedStepIds.has(row.nodeId); + const rowCenterY = position.y + barHeight / 2; + + return ( + +
+ {row.collapsible && ( + event.stopPropagation()} + onClick={(event) => { + event.stopPropagation(); + onToggleCollapse(row.nodeId, !collapsed); + }} + /> + )} + +
+ + + ); +} diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialInsertGapContext.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialInsertGapContext.tsx new file mode 100644 index 000000000..effb6e3df --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialInsertGapContext.tsx @@ -0,0 +1,27 @@ +import { createContext, useContext } from 'react'; +import type { InsertionSlot } from '../../utils/sequential/sequential.types'; + +export type SetSequentialInsertGapSlot = (slot: InsertionSlot) => void; + +const NOOP_SET: SetSequentialInsertGapSlot = () => {}; + +const SequentialInsertGapContext = createContext(NOOP_SET); + +/** Provided by `SequentialCanvas.tsx`, which owns the actual state + the auto-clear-on-close effect. */ +export const SequentialInsertGapProvider = SequentialInsertGapContext.Provider; + +/** + * The setter every `useSequentialInsert()` call site's `startInsert` invokes + * to report the slot an Add Node panel just opened for -- both + * `SequentialCanvas.tsx`'s own terminal-placeholder callsite and every + * `SequentialConnectorEdge`'s "+" button are independent hook instances with + * no state of their own, so the slot needs a SHARED sink for the insert + * -preview -gap post-pass to react regardless of which + * affordance started the insert. `SequentialCanvas.tsx` owns clearing it + * (via `usePreviewNode` transitioning closed, covering both cancel and + * commit). A no-op default keeps isolated edge stories/tests (no + * `SequentialCanvas` mounted) working unchanged. + */ +export function useSetSequentialInsertGapSlot(): SetSequentialInsertGapSlot { + return useContext(SequentialInsertGapContext); +} diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialInsertStateContext.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialInsertStateContext.tsx new file mode 100644 index 000000000..f0948df1d --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialInsertStateContext.tsx @@ -0,0 +1,22 @@ +import { createContext, type MutableRefObject, type ReactNode, useContext, useRef } from 'react'; +import type { PendingSequentialInsert } from './edges/sequentialInsert'; + +interface SequentialInsertState { + pending: MutableRefObject; +} + +const SequentialInsertStateContext = createContext(undefined); + +/** Per-canvas insertion state shared by connector buttons and AddNodeManager. */ +export function SequentialInsertStateProvider({ children }: { children: ReactNode }) { + const pending = useRef(undefined); + return ( + + {children} + + ); +} + +export function useOptionalSequentialInsertState(): SequentialInsertState | undefined { + return useContext(SequentialInsertStateContext); +} diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialMoveActionsContext.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialMoveActionsContext.tsx new file mode 100644 index 000000000..b580290c9 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialMoveActionsContext.tsx @@ -0,0 +1,35 @@ +import { createContext, useContext } from 'react'; +import type { InsertionSlot } from '../../utils/sequential/sequential.types'; +import type { SequentialMoveOptions } from './sequentialMoveActions'; + +export interface SequentialMoveActionsContextValue { + /** The four move candidates for `nodeId` (disabled direction => `undefined`). */ + getMoveOptions: (nodeId: string) => SequentialMoveOptions; + /** + * Applies `moveSubtree(projection, nodeId, slot, {nodes, edges})` through + * `onNodesChange`/`onEdgesChange` (D10: the public API stays the standard + * change callbacks, never a parallel mutation channel). A no-op for a + * degenerate/self-targeting slot, matching `moveSubtree`'s own guard + * (an empty `GraphChangeSet`). + */ + commitMove: (nodeId: string, slot: InsertionSlot) => void; + /** Centers the viewport on a node (used by the `goto` reference chip, D9). */ + centerOnNode: (nodeId: string) => void; +} + +const SequentialMoveActionsContext = createContext( + undefined +); + +/** Provided by `SequentialCanvas.tsx`. */ +export const SequentialMoveActionsProvider = SequentialMoveActionsContext.Provider; + +/** + * Returns the current move-actions binding, or `undefined` outside a provider + * (isolated node/edge stories and tests render `SequentialStepNode` / + * `SequentialConnectorEdge` standalone; both must degrade gracefully -- no + * kebab move items, no reference-chip navigation -- rather than throw). + */ +export function useOptionalSequentialMoveActions(): SequentialMoveActionsContextValue | undefined { + return useContext(SequentialMoveActionsContext); +} diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialViewContext.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialViewContext.tsx new file mode 100644 index 000000000..bcd67846a --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialViewContext.tsx @@ -0,0 +1,71 @@ +import type { Viewport } from '@uipath/apollo-react/canvas/xyflow/react'; +import { createContext, type ReactNode, useCallback, useContext, useMemo, useRef } from 'react'; +import type { CanvasView } from '../../utils/sequential/sequential.types'; +import { useCanvasViewMode } from './useCanvasViewMode'; + +/** + * Coordination hub for a flow/sequential toggle composition. It owns the current + * view (persisted via {@link useCanvasViewMode}) and a per-view viewport store so + * switching views restores the viewport you left behind (the HierarchicalCanvas + * save/restore pattern applied to the view axis, D11). + * + * It is OPTIONAL: SequentialCanvas works standalone without it. A consumer that + * wants a real toggle wraps the canvas in {@link SequentialViewProvider} and + * feeds `view` / `onChange` to the canvas and ViewSwitcher. + */ +export interface SequentialViewContextValue { + view: CanvasView; + setView: (view: CanvasView) => void; + /** Remember a view's viewport before switching away. */ + saveViewport: (view: CanvasView, viewport: Viewport) => void; + /** The viewport to restore when a view mounts (undefined = fit fresh). */ + getViewport: (view: CanvasView) => Viewport | undefined; +} + +const SequentialViewContext = createContext(undefined); + +export interface SequentialViewProviderProps { + children: ReactNode; + /** localStorage key backing the persisted view choice. */ + storageKey: string; + initialView?: CanvasView; +} + +export function SequentialViewProvider({ + children, + storageKey, + initialView = 'flow', +}: SequentialViewProviderProps) { + const [view, setView] = useCanvasViewMode(storageKey, initialView); + const viewportByView = useRef>(new Map()); + + const saveViewport = useCallback((forView: CanvasView, viewport: Viewport) => { + viewportByView.current.set(forView, viewport); + }, []); + + const getViewport = useCallback( + (forView: CanvasView): Viewport | undefined => viewportByView.current.get(forView), + [] + ); + + const value = useMemo( + () => ({ view, setView, saveViewport, getViewport }), + [view, setView, saveViewport, getViewport] + ); + + return {children}; +} + +/** Returns the context or throws when used outside a {@link SequentialViewProvider}. */ +export function useSequentialView(): SequentialViewContextValue { + const context = useContext(SequentialViewContext); + if (!context) { + throw new Error('useSequentialView must be used within a SequentialViewProvider'); + } + return context; +} + +/** Returns the context or undefined, so SequentialCanvas can run standalone. */ +export function useOptionalSequentialView(): SequentialViewContextValue | undefined { + return useContext(SequentialViewContext); +} diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/ToggleHarness.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/ToggleHarness.tsx new file mode 100644 index 000000000..9a7d6f4df --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/ToggleHarness.tsx @@ -0,0 +1,91 @@ +import type { Edge, Node } from '@uipath/apollo-react/canvas/xyflow/react'; +import { applyEdgeChanges, applyNodeChanges } from '@uipath/apollo-react/canvas/xyflow/react'; +import { useCallback, useRef, useState } from 'react'; +import { BaseNode } from '../BaseNode/BaseNode'; +import { SequentialCanvas } from './SequentialCanvas'; +import { SequentialViewProvider, useSequentialView } from './SequentialViewContext'; +import { synthesizePositionsForFlow } from './synthesizePositionsForFlow'; +import { ViewSwitcher } from './ViewSwitcher'; + +export interface ToggleHarnessProps { + initialNodes: Node[]; + initialEdges: Edge[]; + storageKey?: string; + mode?: 'design' | 'view' | 'readonly'; +} + +const FLOW_NODE_TYPES = { default: BaseNode }; + +/** + * Minimal composition demonstrating the flow<->sequential toggle (D11): a single + * canonical nodes/edges state, a ViewSwitcher, and one mounted SequentialCanvas + * that swaps its derived arrays in place. On toggle back to flow, nodes inserted + * while in sequential view (stamped `seqInserted`) get real positions via + * synthesizePositionsForFlow, so the round-trip is lossless. + * + * Used by tests and stories; not part of the public barrel (D13). + */ +export function ToggleHarness({ + initialNodes, + initialEdges, + storageKey = 'sequential-canvas.toggle-harness', + mode = 'design', +}: ToggleHarnessProps) { + return ( + + + + ); +} + +function ToggleHarnessInner({ + initialNodes, + initialEdges, + mode, +}: Required>) { + const { view, setView } = useSequentialView(); + const [nodes, setNodes] = useState(initialNodes); + const [edges, setEdges] = useState(initialEdges); + + const edgesRef = useRef(edges); + edgesRef.current = edges; + const handleViewChange = useCallback( + (nextView: typeof view) => { + if (view === 'sequential' && nextView === 'flow') { + setNodes((current) => synthesizePositionsForFlow(current, edgesRef.current)); + } + setView(nextView); + }, + [view, setView] + ); + + const onNodesChange = useCallback( + (changes: Parameters>[0]) => + setNodes((current) => applyNodeChanges(changes, current)), + [] + ); + const onEdgesChange = useCallback( + (changes: Parameters>[0]) => + setEdges((current) => applyEdgeChanges(changes, current)), + [] + ); + + return ( +
+
+ +
+
+ +
+
+ ); +} diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/ViewSwitcher.test.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/ViewSwitcher.test.tsx new file mode 100644 index 000000000..f461d2f09 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/ViewSwitcher.test.tsx @@ -0,0 +1,15 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { ViewSwitcher } from './ViewSwitcher'; + +describe('ViewSwitcher', () => { + it('renders both view options and calls onChange when the other is picked', () => { + const onChange = vi.fn(); + render(); + + expect(screen.getByRole('radio', { name: 'Flow' })).toBeInTheDocument(); + const sequential = screen.getByRole('radio', { name: 'Sequential' }); + fireEvent.click(sequential); + expect(onChange).toHaveBeenCalledWith('sequential'); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/ViewSwitcher.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/ViewSwitcher.tsx new file mode 100644 index 000000000..27a837f3c --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/ViewSwitcher.tsx @@ -0,0 +1,44 @@ +import { ToggleGroup, ToggleGroupItem } from '@uipath/apollo-wind'; +import { memo } from 'react'; +import { useSafeLingui } from '../../../i18n'; +import { CanvasIcon } from '../../utils/icon-registry'; +import type { CanvasView } from '../../utils/sequential/sequential.types'; +import type { ViewSwitcherProps } from './SequentialCanvas.types'; + +/** + * Segmented flow/sequential control (D11). It is a controlled component: the + * host owns `value` and `onChange` (typically wired to useCanvasViewMode / + * SequentialViewProvider). + */ +function ViewSwitcherComponent({ value, onChange }: ViewSwitcherProps) { + const { _ } = useSafeLingui(); + + const flowLabel = _({ id: 'sequential-canvas.view.flow', message: 'Flow' }); + const sequentialLabel = _({ id: 'sequential-canvas.view.sequential', message: 'Sequential' }); + + return ( +
+ { + // Radix emits '' when the active item is re-pressed; keep the current + // view rather than clearing it (a segmented control is never empty). + if (next === 'flow' || next === 'sequential') onChange(next as CanvasView); + }} + aria-label={_({ id: 'sequential-canvas.view.label', message: 'Canvas view' })} + > + + + {flowLabel} + + + + {sequentialLabel} + + +
+ ); +} + +export const ViewSwitcher = memo(ViewSwitcherComponent); diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialBranchHeader.test.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialBranchHeader.test.tsx new file mode 100644 index 000000000..c92032212 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialBranchHeader.test.tsx @@ -0,0 +1,15 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { SequentialBranchHeader } from './SequentialBranchHeader'; + +describe('SequentialBranchHeader', () => { + it('aligns an edge-styled label immediately above the branch target', () => { + render(); + + const header = screen.getByTestId('sequential-branch-header'); + expect(header).toHaveStyle({ transform: 'translate(128px, 238px) translateY(-100%)' }); + const label = screen.getByText('False'); + expect(label).toHaveClass('react-flow__edge-label'); + expect(label.getAttribute('style')).toContain('var(--canvas-primary)'); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialBranchHeader.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialBranchHeader.tsx new file mode 100644 index 000000000..d2c2ab506 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialBranchHeader.tsx @@ -0,0 +1,37 @@ +import { EdgeLabelRenderer } from '@uipath/apollo-react/canvas/xyflow/react'; +import { EdgeLabelContent } from '../../Edges/shared/primitives'; + +const BRANCH_HEADER_BOTTOM_GAP = 2; + +export interface SequentialBranchHeaderProps { + x: number; + targetTopY: number; + label: string; + selected?: boolean; +} + +/** + * Introduces a branch with the shared edge-label treatment immediately above + * its first row. The header is owned by the branch's content area and therefore + * remains stable when connector routes change. + */ +export function SequentialBranchHeader({ + x, + targetTopY, + label, + selected, +}: SequentialBranchHeaderProps) { + const anchorY = targetTopY - BRANCH_HEADER_BOTTOM_GAP; + + return ( + +
+ +
+
+ ); +} diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialConnectorEdge.stories.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialConnectorEdge.stories.tsx new file mode 100644 index 000000000..e95848ce9 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialConnectorEdge.stories.tsx @@ -0,0 +1,218 @@ +/** + * SequentialConnectorEdge stories. + * + * Hardcoded bar fixtures (no dependency on the projection engine or the bar + * node) that exercise the four connector kinds, branch label pills, and the + * always-visible centered insert affordance. + */ +import type { Meta, StoryObj } from '@storybook/react'; +import type { Edge, Node, NodeProps } from '@uipath/apollo-react/canvas/xyflow/react'; +import { Handle, Position } from '@uipath/apollo-react/canvas/xyflow/react'; +import { useMemo } from 'react'; +import { SEQ_BAR_HEIGHT, SEQ_BAR_WIDTH } from '../../../constants'; +import { + createNode as createMockNode, + StoryInfoPanel, + useCanvasStory, + withCanvasProviders, +} from '../../../storybook-utils'; +import { AddNodeManager } from '../../AddNodePanel'; +import { BaseCanvas } from '../../BaseCanvas'; +import type { Waypoint } from '../../Edges/shared/types'; +import { SequentialConnectorEdge } from './SequentialConnectorEdge'; +import type { SequentialConnectorData } from './SequentialConnectorEdge.types'; +import { SEQUENTIAL_IGNORED_NODE_TYPES, sequentialOnBeforeNodeAdded } from './sequentialInsert'; + +const meta: Meta = { + title: 'Components/SequentialCanvas/SequentialConnectorEdge', + parameters: { layout: 'fullscreen' }, + decorators: [withCanvasProviders()], +}; + +export default meta; +type Story = StoryObj; + +const STORY_BAR_TYPE = 'story-bar'; +const edgeTypes = { sequentialConnector: SequentialConnectorEdge }; + +/** Minimal bar-shaped node used only by these edge stories. */ +function StoryBarNode({ data }: NodeProps) { + const label = (data as { label?: string }).label ?? ''; + return ( +
+ + {label} + +
+ ); +} + +const additionalNodeTypes = { [STORY_BAR_TYPE]: StoryBarNode }; + +function bar(id: string, label: string, x: number, y: number): Node { + return { + id, + type: STORY_BAR_TYPE, + position: { x, y }, + width: SEQ_BAR_WIDTH, + height: SEQ_BAR_HEIGHT, + draggable: false, + data: { label }, + }; +} + +function connector( + id: string, + source: string, + target: string, + data: SequentialConnectorData +): Edge { + return { + id, + source, + target, + sourceHandle: 'output', + targetHandle: 'in', + type: 'sequentialConnector', + data, + }; +} + +function AllKindsStory() { + const initialNodes = useMemo( + () => [ + bar('http', 'HTTP Request', 0, 0), + bar('decision', 'Decision', 0, 160), + bar('then', 'Post to Slack', 64, 320), + bar('else', 'Log warning', 64, 480), + bar('merge', 'Return result', 0, 672), + ], + [] + ); + + const mergeWaypoints: Waypoint[] = useMemo( + () => [ + { id: 'mb-1', x: 512, y: 600 }, + { id: 'mb-2', x: 448, y: 600 }, + ], + [] + ); + + const gotoWaypoints: Waypoint[] = useMemo( + () => [ + { id: 'gt-1', x: 1000, y: 552 }, + { id: 'gt-2', x: 1000, y: 160 }, + ], + [] + ); + + const initialEdges = useMemo( + () => [ + connector('e-step', 'http', 'decision', { + kind: 'step', + slot: { id: 's-step', graphEdgeId: 'e-step' }, + }), + connector('e-then', 'decision', 'then', { + kind: 'branch-entry', + label: 'Then', + slot: { id: 's-then', graphEdgeId: 'e-then' }, + }), + connector('e-else', 'decision', 'else', { + kind: 'branch-entry', + label: 'Else', + slot: { id: 's-else', graphEdgeId: 'e-else' }, + }), + connector('e-merge', 'then', 'merge', { + kind: 'merge-back', + waypoints: mergeWaypoints, + }), + connector('e-goto', 'else', 'decision', { + kind: 'goto', + waypoints: gotoWaypoints, + }), + ], + [mergeWaypoints, gotoWaypoints] + ); + + const { canvasProps } = useCanvasStory({ initialNodes, initialEdges, additionalNodeTypes }); + + return ( + + + Solid step and branch-entry connectors carry an arrow and a centered insert affordance. + Branch entries add a "Then" or "Else" pill. Dashed merge-back and goto connectors render + their layout waypoints verbatim and carry no insert affordance. + + + ); +} + +export const AllConnectorKinds: Story = { + render: () => , +}; + +function InsertFlowStory() { + // Real registered manifest nodes so the Add Node panel's constraint filtering + // (registry.getManifest / getNodeOptions) works end-to-end. Bar visuals are + // out of scope here; nodes render as the default node so the connector + pipeline are + // what's under test. + const initialNodes = useMemo( + () => [ + createMockNode({ + id: 'a', + type: 'uipath.blank-node', + position: { x: 0, y: 0 }, + display: { label: 'HTTP Request' }, + handleConfigurations: [ + { + position: Position.Bottom, + handles: [{ id: 'output', type: 'source' as const, handleType: 'output' as const }], + }, + ], + }), + createMockNode({ + id: 'b', + type: 'uipath.blank-node', + position: { x: 0, y: 160 }, + display: { label: 'Return result' }, + handleConfigurations: [ + { + position: Position.Top, + handles: [{ id: 'in', type: 'target' as const, handleType: 'input' as const }], + }, + ], + }), + ], + [] + ); + + const initialEdges = useMemo( + () => [ + connector('e-1', 'a', 'b', { + kind: 'step', + slot: { id: 's-1', graphEdgeId: 'e-1' }, + }), + ], + [] + ); + + const { canvasProps } = useCanvasStory({ initialNodes, initialEdges, additionalNodeTypes }); + + return ( + + + Click the centered plus. The Add Node panel opens through the existing preview pipeline and + a bar-sized preview row opens the slot between the two bars. Inserts are stamped seqInserted + and use a random id, and the sequential clone types are ignored by the collision pass. + + + + ); +} + +export const InsertOpensSlot: Story = { + render: () => , +}; 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..8e803f4a6 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialConnectorEdge.test.tsx @@ -0,0 +1,79 @@ +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'); + }); + }); + + it('centers a left-entry arrow using the target node height', () => { + expect(getLeftEntryArrowTargetY(200, 999, 48)).toBe(224); + expect(getLeftEntryArrowTargetY(undefined, 200, undefined)).toBe(228); + }); + + describe('step kind insert affordance', () => { + it('renders the insert button when a slot is present and the canvas is in design mode', () => { + renderEdge({ kind: 'step', slot: { id: 's-1', graphEdgeId: 'e1' } }, 'design'); + const button = screen.getByRole('button', { name: 'Insert step' }); + expect(button).toBeInTheDocument(); + expect(button).toHaveClass( + 'opacity-0', + '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..15b51ad1f --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialConnectorEdge.tsx @@ -0,0 +1,262 @@ +import { Position, useStore } from '@uipath/apollo-react/canvas/xyflow/react'; +import { memo, useCallback, useState } from 'react'; +import { useSafeLingui } from '../../../../i18n'; +import { SEQ_BAR_HEIGHT, SEQ_EDGE_CORNER_RADIUS } from '../../../constants'; +import { useBaseCanvasMode } from '../../BaseCanvas/BaseCanvasModeProvider'; +import { areEdgePropsEqual } from '../../Edges/shared/areEdgePropsEqual'; +import { EMPTY_WAYPOINTS } from '../../Edges/shared/constants'; +import { useEdgeGeometry, useExecutionEdge } from '../../Edges/shared/hooks'; +import { EdgeArrow, EdgeLabel, EdgePath } from '../../Edges/shared/primitives'; +import { resolveEdgeColor } from '../../Edges/shared/resolveEdgeColor'; +import { SequentialBranchHeader } from './SequentialBranchHeader'; +import type { SequentialConnectorEdgeProps } from './SequentialConnectorEdge.types'; +import { SequentialInsertButton } from './SequentialInsertButton'; +import { resolveConnectorStrokeStyle } from './sequentialConnectorStyle'; +import { useSequentialInsert } from './useSequentialInsert'; + +/** + * Vertical offset (flow px) between a NON-branch connector's label pill and its + * centered ⊕ (the ⊕ owns the connector's true midpoint; the label is nudged up). + * Branch-entry connectors instead split the two onto separate segments of their + * elbow (label on the vertical spine, ⊕ on the horizontal jog) so they never + * crowd - see the branch placement below. + */ +const INSERT_BUTTON_LABEL_OFFSET_PX = 26; + +/** + * Sequential arrowhead placement. Flow-view edges translate the arrow + * ARROW_OFFSET px INTO the target node so the node paints over the tuck; + * sequential bars are opaque and connectors render behind them, so a big tuck is + * clipped. The tip is anchored to the target bar's real top edge (read from the + * store, see `targetTopY`); this 1px nudge lets it bite just into the bar so it + * reads as connected rather than leaving a hairline gap, with the body in the + * clear row gap above it so it stays fully visible without elevating the edge. + */ +const ARROW_TIP_AT_BAR_EDGE = { x: 0, y: 1 } as const; +/** Same 1px bite, but into the LEFT face for branch-entry (mid-left) arrows. */ +const ARROW_TIP_AT_BAR_LEFT_EDGE = { x: 1, y: 0 } as const; + +export function getLeftEntryArrowTargetY( + targetTopY: number | undefined, + targetY: number, + targetHeight: number | undefined +): number { + return (targetTopY ?? targetY) + (targetHeight ?? SEQ_BAR_HEIGHT) / 2; +} + +/** + * Sequential-view connector edge. Composes the shared edge primitives + * (EdgePath / EdgeArrow / EdgeLabel) and renders behavior from `data`: + * + * - stroke style by kind ('step' / 'branch-entry' solid; 'merge-back' / 'goto' + * dashed) via {@link resolveConnectorStrokeStyle}; + * - an edge-styled label above the first row of each labeled branch; + * - `data.waypoints` rendered verbatim (elbows supplied by layoutSequence; no + * router, no waypoint editing); + * - a statically centered ⊕ insert affordance, revealed on hover/focus and + * available only in design mode when `data.slot` is present. + * + * See ./SequentialConnectorEdge.types.ts for the field-by-field `data` contract + * the assembly builds. `data` is compared by reference (areEdgePropsEqual), so the assembly must + * keep it reference-stable across data-only changes (D12). + */ +export const SequentialConnectorEdge = memo(function SequentialConnectorEdge({ + id, + selected, + source, + target, + sourceX, + sourceY, + sourcePosition = Position.Bottom, + targetX, + targetY, + targetPosition = Position.Top, + sourceHandleId, + targetHandleId, + style, + data, +}: SequentialConnectorEdgeProps) { + const { _ } = useSafeLingui(); + const isDesignMode = useBaseCanvasMode().mode === 'design'; + const { startInsert } = useSequentialInsert(); + + // Real top-left corner of the target bar. The handle-derived `targetX/Y` sit + // slightly off the bar's painted edge, so anchoring the arrowhead to the + // node's absolute position lands the tip exactly on the bar — its top edge for + // a normal (Top) entry, its left edge at mid-height for a branch (Left) entry. + const targetTopY = useStore((s) => s.nodeLookup.get(target)?.internals.positionAbsolute.y); + const targetLeftX = useStore((s) => s.nodeLookup.get(target)?.internals.positionAbsolute.x); + const targetHeight = useStore((s) => { + const targetNode = s.nodeLookup.get(target); + // Sequential rows publish their layout-owned height immediately. Prefer it + // over the measured value, which can trail a slider update by one render. + return targetNode?.height ?? targetNode?.measured?.height; + }); + // Branch-entry connectors carry a Left target handle, so React Flow reports + // Position.Left here — the arrow then points right into the bar's mid-left. + const isLeftEntry = targetPosition === Position.Left; + + const [isHovered, setIsHovered] = useState(false); + const onMouseEnter = useCallback(() => setIsHovered(true), []); + const onMouseLeave = useCallback(() => setIsHovered(false), []); + + const kind = data?.kind ?? 'step'; + const label = data?.label; + const slot = data?.slot; + const hideArrowHead = !!data?.hideArrowHead; + const isPreview = !!data?.preview; + const waypoints = data?.waypoints ?? EMPTY_WAYPOINTS; + + // Waypoint routing is used for every kind: elbowed connectors carry explicit + // waypoints, and straight step connectors get a clean orthogonal path between + // the two anchors from an empty waypoint list. + const geometry = useEdgeGeometry({ + routing: 'waypoint', + sourceNodeId: source, + targetNodeId: target, + sourceHandleId, + targetHandleId, + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + waypoints, + enableSegments: false, + hideArrowHead, + // Pronounced smooth-step corners for the sequential view: the elbowed + // branch/merge-back connectors read as soft rounded steps rather than the + // subtler default. createRoundedPath clamps this to half the shorter + // adjacent segment, so tight jogs degrade gracefully. + borderRadius: SEQ_EDGE_CORNER_RADIUS, + }); + + const execution = useExecutionEdge({ + edgeId: id, + target, + edgePath: geometry.edgePath, + enabled: !!data?.enableExecution, + }); + + const color = resolveEdgeColor({ + selected, + isHovered, + previewEdge: isPreview, + statusColor: execution.statusColor, + }); + + const strokeStyle = isPreview ? 'dashed' : resolveConnectorStrokeStyle(kind); + const showInsert = isDesignMode && !!slot; + const hasLabel = typeof label === 'string' && label.length > 0; + + // Branch-entry connectors elbow: a vertical spine drop from the owner then a + // horizontal jog into the target (waypoints [spineBottom, jogEnd] from + // layoutSequence). The ⊕ stays on that horizontal jog. Its label is rendered + // separately above the target row, so labels no longer + // compete with connector geometry or one another. + const wp0 = waypoints[0]; + const wp1 = waypoints[1]; + const isBranchElbow = kind === 'branch-entry' && wp0 !== undefined && wp1 !== undefined; + const insertPoint = isBranchElbow ? { x: (wp0.x + wp1.x) / 2, y: wp0.y } : geometry.labelPoint; + const labelPoint = showInsert + ? { x: geometry.labelPoint.x, y: geometry.labelPoint.y - INSERT_BUTTON_LABEL_OFFSET_PX } + : geometry.labelPoint; + const resolvedTargetHeight = typeof targetHeight === 'number' ? targetHeight : SEQ_BAR_HEIGHT; + const resolvedTargetTopY = + typeof targetTopY === 'number' + ? targetTopY + : isLeftEntry + ? targetY - resolvedTargetHeight / 2 + : targetY; + const resolvedTargetLeftX = typeof targetLeftX === 'number' ? targetLeftX : targetX; + + const onInsert = useCallback(() => { + if (!slot) return; + startInsert({ + slot, + source, + sourceHandleId, + target, + targetHandleId, + sourcePosition, + position: { x: (sourceX + targetX) / 2, y: (sourceY + targetY) / 2 }, + // This edge's OWN rendered id — the store in sequential view holds + // derived connector edges, never canonical ids, so the split connector + // must be found (and stashed for cancel-restore) by this id, not + // slot.graphEdgeId. + connectorEdgeId: id, + }); + }, [ + slot, + source, + sourceHandleId, + target, + targetHandleId, + sourcePosition, + sourceX, + sourceY, + targetX, + targetY, + startInsert, + id, + ]); + + const opacity = (style?.opacity as number | undefined) ?? 1; + + return ( + <> + + + + {!hideArrowHead && ( + + )} + + {execution.animation} + + {hasLabel && kind !== 'branch-entry' && ( + + )} + + + {hasLabel && kind === 'branch-entry' && ( + + )} + + {showInsert && ( + + )} + + ); +}, areEdgePropsEqual); diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialConnectorEdge.types.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialConnectorEdge.types.ts new file mode 100644 index 000000000..bbca153db --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialConnectorEdge.types.ts @@ -0,0 +1,62 @@ +import type { Edge, EdgeProps } from '@uipath/apollo-react/canvas/xyflow/react'; +import type { + InsertionSlot, + SequenceConnectorKind, +} from '../../../utils/sequential/sequential.types'; +import type { Waypoint } from '../../Edges/shared/types'; + +/** + * Reference-stable `data` payload for a sequential connector edge. + * + * The assembly builds one of these per {@link SequenceConnector} when it derives the + * xyflow edge array (see useSequentialGraph). The object MUST be built only on + * structural change and reused by reference otherwise, so `areEdgePropsEqual` + * (which compares `data` by reference, see components/Edges/shared/areEdgePropsEqual.ts) + * can short-circuit the memo during pan/zoom and data-only edits (D12). + * + * Field-by-field contract: + * - `kind` copy from `connector.kind`. Drives stroke style: + * 'step' | 'branch-entry' render solid; 'merge-back' | + * 'goto' render dashed. See {@link resolveConnectorStrokeStyle}. + * - `label` copy from `connector.label`. Branch-entry labels render + * above their first row using the shared edge-label style; + * other kinds + * retain the centered edge pill. Omit or use an empty + * string to render no label. + * - `waypoints` elbow geometry from `layoutSequence().connectorWaypoints` + * keyed by the connector id. Rendered verbatim (no router, + * no editing). When absent, the edge draws a clean + * orthogonal path between the two handle anchors. Reuse the + * SAME array reference across renders; a fresh `[]` each + * render defeats geometry memoization (prefer the shared + * EMPTY_WAYPOINTS fallback). + * - `slot` copy from `connector.slot`. The centered insert (+) + * button renders IFF this is present AND the canvas is in + * design mode. `goto` connectors carry no slot and so get + * no button. + * - `enableExecution` opt in to id-keyed execution coloring + the in-progress + * dot, via the existing ExecutionStatusContext. Defaults to + * off; turning it on matches CanvasEdge behavior. + * - `hideArrowHead` suppress the arrow head. Defaults to showing an arrow + * pointing into the target for every kind. + */ +// A `type` (not an `interface`) so it implicitly satisfies xyflow's +// `Edge>` constraint, matching CanvasEdgeData. +export type SequentialConnectorData = { + kind: SequenceConnectorKind; + label?: string | null; + waypoints?: Waypoint[]; + slot?: InsertionSlot; + /** Transient Add Node connector: selected color + dashed preview stroke. */ + preview?: boolean; + /** Visual-only preview join that AddNodeManager must not materialize. */ + ignorePreviewConnection?: boolean; + enableExecution?: boolean; + hideArrowHead?: boolean; +}; + +/** The xyflow edge shape rendered by {@link SequentialConnectorEdge}. */ +export type SequentialConnectorEdgeType = Edge; + +/** Props xyflow hands the edge component. */ +export type SequentialConnectorEdgeProps = EdgeProps; diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialInsertButton.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialInsertButton.tsx new file mode 100644 index 000000000..190f189e8 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialInsertButton.tsx @@ -0,0 +1,57 @@ +import { EdgeLabelRenderer, type XYPosition } from '@uipath/apollo-react/canvas/xyflow/react'; +import { type MouseEvent as ReactMouseEvent, useCallback } from 'react'; +import { CanvasInlineButton } from '../../ButtonHandle/CanvasInlineButton'; + +export interface SequentialInsertButtonProps { + /** Center point in flow coordinates (the connector midpoint). */ + point: XYPosition; + /** Accessible label, already localized. */ + label: string; + /** Invoked on click; opens the Add Node panel for the connector's slot. */ + onInsert: () => void; +} + +/** + * Statically centered ⊕ affordance for a sequential connector (design mode + * only; the caller gates on slot presence + mode). It stays in the DOM for + * keyboard navigation but is visually revealed on hover/focus, avoiding a wall + * of competing plus buttons when several nested insertion slots are nearby. + * Unlike the + * hover-following EdgeToolbar (see Toolbar/EdgeToolbar/useEdgeToolbarPositioning.ts), + * this sits at a fixed point on the connector and never tracks the pointer. + * + * It stops mousedown/click propagation so opening the Add Node panel does not + * trip the Toolbox's outside-mousedown close (components/Toolbox/Toolbox.tsx:621-625). + */ +export function SequentialInsertButton({ point, label, onInsert }: SequentialInsertButtonProps) { + const stopMouseDown = useCallback((event: ReactMouseEvent) => { + event.stopPropagation(); + }, []); + + const handleClick = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation(); + event.preventDefault(); + onInsert(); + }, + [onInsert] + ); + + return ( + +
+ +
+
+ ); +} diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/index.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/index.ts new file mode 100644 index 000000000..9443e7101 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/index.ts @@ -0,0 +1,24 @@ +// Local barrel for the sequential connector + insert pipeline. This is NOT +// re-exported from components/index.ts; the feature stays out of the public +// canvas barrel until GA (D13). Types first, component/helpers after. + +export { SequentialBranchHeader } from './SequentialBranchHeader'; +export { SequentialConnectorEdge } from './SequentialConnectorEdge'; +export type { + SequentialConnectorData, + SequentialConnectorEdgeProps, + SequentialConnectorEdgeType, +} from './SequentialConnectorEdge.types'; +export type { SequentialInsertButtonProps } from './SequentialInsertButton'; +export { SequentialInsertButton } from './SequentialInsertButton'; +export { resolveConnectorStrokeStyle } from './sequentialConnectorStyle'; +export type { SequentialInsertArgs } from './sequentialInsert'; +export { + buildSequentialPreviewOptions, + getSequentialIgnoredNodeTypes, + SEQ_INSERTED_FLAG, + SEQUENTIAL_IGNORED_NODE_TYPES, + sequentialOnBeforeNodeAdded, +} from './sequentialInsert'; +export type { UseSequentialInsertResult } from './useSequentialInsert'; +export { useSequentialInsert } from './useSequentialInsert'; diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/sequentialConnectorStyle.test.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/sequentialConnectorStyle.test.ts new file mode 100644 index 000000000..d091f1e72 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/sequentialConnectorStyle.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import type { SequenceConnectorKind } from '../../../utils/sequential/sequential.types'; +import { resolveConnectorStrokeStyle } from './sequentialConnectorStyle'; + +describe('resolveConnectorStrokeStyle', () => { + it('renders structural spine connectors solid', () => { + expect(resolveConnectorStrokeStyle('step')).toBe('solid'); + expect(resolveConnectorStrokeStyle('branch-entry')).toBe('solid'); + }); + + it('renders rejoin / reference connectors dashed', () => { + expect(resolveConnectorStrokeStyle('merge-back')).toBe('dashed'); + expect(resolveConnectorStrokeStyle('goto')).toBe('dashed'); + }); + + it('covers every connector kind', () => { + const kinds: SequenceConnectorKind[] = ['step', 'branch-entry', 'merge-back', 'goto']; + for (const kind of kinds) { + expect(['solid', 'dashed']).toContain(resolveConnectorStrokeStyle(kind)); + } + }); +}); diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/sequentialConnectorStyle.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/sequentialConnectorStyle.ts new file mode 100644 index 000000000..b88614938 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/sequentialConnectorStyle.ts @@ -0,0 +1,13 @@ +import type { SequenceConnectorKind } from '../../../utils/sequential/sequential.types'; +import type { EdgeStrokeStyle } from '../../Edges/shared/types'; + +/** + * Maps a connector kind to its stroke style. + * + * - `step` / `branch-entry` are part of the structural spine: solid. + * - `merge-back` / `goto` are the "degrade" rejoin/reference connectors and + * read as secondary: dashed (D9). + */ +export function resolveConnectorStrokeStyle(kind: SequenceConnectorKind): EdgeStrokeStyle { + return kind === 'merge-back' || kind === 'goto' ? 'dashed' : 'solid'; +} diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/sequentialInsert.test.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/sequentialInsert.test.ts new file mode 100644 index 000000000..2b84d9557 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/sequentialInsert.test.ts @@ -0,0 +1,408 @@ +import type { Edge, Node, ReactFlowInstance } from '@uipath/apollo-react/canvas/xyflow/react'; +import { Position } from '@uipath/apollo-react/canvas/xyflow/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + DEFAULT_SOURCE_HANDLE_ID, + DEFAULT_TARGET_HANDLE_ID, + SEQ_BAR_HEIGHT, + SEQ_BAR_WIDTH, + SEQ_PLACEHOLDER_NODE_TYPE, + SEQ_START_NODE_TYPE, +} from '../../../constants'; +import type { InsertionSlot } from '../../../utils/sequential/sequential.types'; +import { SEQUENTIAL_BAR_HANDLE_IDS } from '../nodes'; +import { + buildSequentialPreviewOptions, + getSequentialIgnoredNodeTypes, + type PendingSequentialInsert, + resolvePendingSequentialInsert, + SEQ_INSERTED_FLAG, + SEQUENTIAL_IGNORED_NODE_TYPES, + sequentialOnBeforeNodeAdded, +} from './sequentialInsert'; + +const FAKE_UUID = '00000000-0000-0000-0000-000000000001'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('SEQUENTIAL_IGNORED_NODE_TYPES', () => { + it('lists exactly the synthetic sequential row types (clones keep real types)', () => { + expect(SEQUENTIAL_IGNORED_NODE_TYPES).toEqual([SEQ_START_NODE_TYPE, SEQ_PLACEHOLDER_NODE_TYPE]); + }); + + it('merges host extras into a fresh array', () => { + const merged = getSequentialIgnoredNodeTypes(['sticky']); + expect(merged).toEqual([...SEQUENTIAL_IGNORED_NODE_TYPES, 'sticky']); + expect(merged).not.toBe(SEQUENTIAL_IGNORED_NODE_TYPES); + }); +}); + +describe('sequentialOnBeforeNodeAdded', () => { + const previousId = 'uipath.slack-1720000000000'; + + const seedNode = (): Node => + ({ + id: previousId, + type: 'uipath.slack', + position: { x: 420, y: 96 }, + data: { label: 'Slack', display: { label: 'Send message' } }, + }) as Node; + + const seedEdges = (): Edge[] => [ + { + id: `edge_srcA-output-${previousId}-input`, + source: 'srcA', + sourceHandle: 'output', + target: previousId, + targetHandle: 'input', + type: 'default', + } as Edge, + { + id: `edge_${previousId}-output-tgtB-input`, + source: previousId, + sourceHandle: 'output', + target: 'tgtB', + targetHandle: 'input', + type: 'default', + } as Edge, + ]; + + it('re-ids the node with crypto.randomUUID()', () => { + const spy = vi.spyOn(crypto, 'randomUUID').mockReturnValue(FAKE_UUID); + const { newNode } = sequentialOnBeforeNodeAdded(seedNode(), seedEdges()); + expect(spy).toHaveBeenCalledTimes(1); + expect(newNode.id).toBe(FAKE_UUID); + }); + + it('keeps the preview-derived position and does not force draggable false', () => { + // The sequential clone derivation (useSequentialGraph) already stamps + // draggable:false on every row unconditionally, so onBeforeNodeAdded must + // not also write it into canonical state, or it leaks permanently once the + // user toggles back to flow view (synthesizePositionsForFlow only clears it + // on that boundary; nothing clears it while still in sequential view). + vi.spyOn(crypto, 'randomUUID').mockReturnValue(FAKE_UUID); + const seed = seedNode(); + const { newNode } = sequentialOnBeforeNodeAdded(seed, seedEdges()); + expect(newNode.position).toEqual(seed.position); + expect(newNode.draggable).toBeUndefined(); + }); + + it('stamps seqInserted while preserving existing data and type', () => { + vi.spyOn(crypto, 'randomUUID').mockReturnValue(FAKE_UUID); + const { newNode } = sequentialOnBeforeNodeAdded(seedNode(), seedEdges()); + expect(newNode.type).toBe('uipath.slack'); + expect((newNode.data as Record)[SEQ_INSERTED_FLAG]).toBe(true); + expect((newNode.data as Record).label).toBe('Slack'); + }); + + it('rewrites edges that reference the seed id and regenerates their ids', () => { + vi.spyOn(crypto, 'randomUUID').mockReturnValue(FAKE_UUID); + const { newEdges } = sequentialOnBeforeNodeAdded(seedNode(), seedEdges()); + + const incoming = newEdges.find((edge) => edge.source === 'srcA'); + const outgoing = newEdges.find((edge) => edge.target === 'tgtB'); + + expect(incoming?.target).toBe(FAKE_UUID); + expect(incoming?.id).toBe(`edge_srcA-output-${FAKE_UUID}-input`); + expect(outgoing?.source).toBe(FAKE_UUID); + expect(outgoing?.id).toBe(`edge_${FAKE_UUID}-output-tgtB-input`); + }); + + it('leaves an unrelated edge untouched by reference', () => { + vi.spyOn(crypto, 'randomUUID').mockReturnValue(FAKE_UUID); + const unrelated = { + id: 'edge_x-output-y-input', + source: 'x', + target: 'y', + type: 'default', + } as Edge; + const { newEdges } = sequentialOnBeforeNodeAdded(seedNode(), [unrelated]); + expect(newEdges[0]).toBe(unrelated); + }); + + describe('with a pending slot (canonical handle/containment rewriting)', () => { + const barEdges = (): Edge[] => [ + { + id: `edge_srcA-${SEQUENTIAL_BAR_HANDLE_IDS.source}-${previousId}-${SEQUENTIAL_BAR_HANDLE_IDS.target}`, + source: 'srcA', + sourceHandle: SEQUENTIAL_BAR_HANDLE_IDS.source, + target: previousId, + targetHandle: SEQUENTIAL_BAR_HANDLE_IDS.target, + type: 'default', + } as Edge, + { + id: `edge_${previousId}-${SEQUENTIAL_BAR_HANDLE_IDS.source}-tgtB-${SEQUENTIAL_BAR_HANDLE_IDS.target}`, + source: previousId, + sourceHandle: SEQUENTIAL_BAR_HANDLE_IDS.source, + target: 'tgtB', + targetHandle: SEQUENTIAL_BAR_HANDLE_IDS.target, + type: 'default', + } as Edge, + ]; + + it('restores only the original nodes canonical handles', () => { + vi.spyOn(crypto, 'randomUUID').mockReturnValue(FAKE_UUID); + const pending: PendingSequentialInsert = { + sourceNodeId: 'srcA', + targetNodeId: 'tgtB', + sourceHandleId: 'true', + targetHandleId: 'input', + }; + const { newEdges } = sequentialOnBeforeNodeAdded(seedNode(), barEdges(), pending); + + const incoming = newEdges.find((edge) => edge.source === 'srcA'); + const outgoing = newEdges.find((edge) => edge.target === 'tgtB'); + + expect(incoming?.sourceHandle).toBe('true'); + expect(outgoing?.targetHandle).toBe('input'); + // The inserted node's own handles are not replaced with handles that + // belong to the original source/target nodes. + expect(incoming?.targetHandle).toBe(SEQUENTIAL_BAR_HANDLE_IDS.target); + expect(outgoing?.sourceHandle).toBe(SEQUENTIAL_BAR_HANDLE_IDS.source); + }); + + it('restores implicit defaults only on the original node endpoints', () => { + vi.spyOn(crypto, 'randomUUID').mockReturnValue(FAKE_UUID); + const { newEdges } = sequentialOnBeforeNodeAdded(seedNode(), barEdges(), { + sourceNodeId: 'srcA', + targetNodeId: 'tgtB', + }); + + const incoming = newEdges.find((edge) => edge.source === 'srcA'); + const outgoing = newEdges.find((edge) => edge.target === 'tgtB'); + + expect(incoming?.sourceHandle).toBeUndefined(); + expect(outgoing?.targetHandle).toBeUndefined(); + expect(incoming?.targetHandle).toBe(SEQUENTIAL_BAR_HANDLE_IDS.target); + expect(outgoing?.sourceHandle).toBe(SEQUENTIAL_BAR_HANDLE_IDS.source); + }); + + it('regenerates the edge id from the rewritten handles so the id matches the fields', () => { + vi.spyOn(crypto, 'randomUUID').mockReturnValue(FAKE_UUID); + const pending: PendingSequentialInsert = { + sourceNodeId: 'srcA', + targetNodeId: 'tgtB', + sourceHandleId: 'true', + targetHandleId: 'input', + }; + const { newEdges } = sequentialOnBeforeNodeAdded(seedNode(), barEdges(), pending); + const outgoing = newEdges.find((edge) => edge.source === FAKE_UUID); + expect(outgoing?.id).toBe(`edge_${FAKE_UUID}-${SEQUENTIAL_BAR_HANDLE_IDS.source}-tgtB-input`); + }); + + it('tags both split halves with the exact canonical edge id', () => { + vi.spyOn(crypto, 'randomUUID').mockReturnValue(FAKE_UUID); + const { newEdges } = sequentialOnBeforeNodeAdded(seedNode(), seedEdges(), { + sourceNodeId: 'srcA', + targetNodeId: 'tgtB', + graphEdgeId: 'canonical-edge-7', + sourceHandleId: 'output', + targetHandleId: 'input', + }); + + expect(newEdges).toHaveLength(2); + for (const edge of newEdges) { + expect((edge.data as Record).__sequentialSplitEdgeId).toBe( + 'canonical-edge-7' + ); + } + }); + + it('leaves non-bar handle ids untouched even when a pending value is present', () => { + vi.spyOn(crypto, 'randomUUID').mockReturnValue(FAKE_UUID); + const pending: PendingSequentialInsert = { + sourceNodeId: 'srcA', + targetNodeId: 'tgtB', + sourceHandleId: 'ignored', + targetHandleId: 'ignored', + }; + const { newEdges } = sequentialOnBeforeNodeAdded(seedNode(), seedEdges(), pending); + const incoming = newEdges.find((edge) => edge.source === 'srcA'); + const outgoing = newEdges.find((edge) => edge.target === 'tgtB'); + expect(incoming?.sourceHandle).toBe('ignored'); + expect(outgoing?.targetHandle).toBe('ignored'); + expect(incoming?.targetHandle).toBe('input'); + expect(outgoing?.sourceHandle).toBe('output'); + }); + + it('applies the slot containerId as canonical parentId/extent on the materialized node', () => { + vi.spyOn(crypto, 'randomUUID').mockReturnValue(FAKE_UUID); + const { newNode } = sequentialOnBeforeNodeAdded(seedNode(), seedEdges(), { + containerId: 'loop-1', + }); + expect(newNode.parentId).toBe('loop-1'); + expect(newNode.extent).toBe('parent'); + }); + + it('omits parentId/extent when the slot has no container', () => { + vi.spyOn(crypto, 'randomUUID').mockReturnValue(FAKE_UUID); + const { newNode } = sequentialOnBeforeNodeAdded(seedNode(), seedEdges(), {}); + expect(newNode.parentId).toBeUndefined(); + expect(newNode.extent).toBeUndefined(); + }); + }); +}); + +describe('resolvePendingSequentialInsert', () => { + it('captures the slot canonical handle ids and containerId', () => { + const slot: InsertionSlot = { + id: 'slot-1', + source: { nodeId: 'a', handleId: 'true' }, + target: { nodeId: 'b', handleId: 'input' }, + containerId: 'loop-1', + }; + expect(resolvePendingSequentialInsert(slot)).toEqual({ + sourceNodeId: 'a', + targetNodeId: 'b', + graphEdgeId: undefined, + sourceHandleId: 'true', + targetHandleId: 'input', + containerId: 'loop-1', + }); + }); + + it('yields undefined handle ids (never a bar handle) when the slot endpoint omits one', () => { + const slot: InsertionSlot = { id: 'slot-1', source: { nodeId: 'a' }, target: { nodeId: 'b' } }; + const pending = resolvePendingSequentialInsert(slot); + expect(pending.sourceHandleId).toBeUndefined(); + expect(pending.targetHandleId).toBeUndefined(); + }); + + it('yields no containerId when the slot is not scoped to a container', () => { + const slot: InsertionSlot = { id: 'slot-1' }; + expect(resolvePendingSequentialInsert(slot).containerId).toBeUndefined(); + }); +}); + +describe('buildSequentialPreviewOptions', () => { + // The connector's rendered id deliberately DIFFERS from the canonical edge id + // it wraps (as the projection's `conn:${edge.id}` scheme actually produces), proving the + // lookup uses connectorEdgeId (the rendered id) and not slot.graphEdgeId (the + // canonical id) — those live in different id spaces in sequential view, since + // the store here only ever holds derived connector edges. + const connectorEdge = { + id: 'conn:e-1', + source: 'row-a', + sourceHandle: SEQUENTIAL_BAR_HANDLE_IDS.source, + target: 'row-b', + targetHandle: SEQUENTIAL_BAR_HANDLE_IDS.target, + type: 'sequentialConnector', + } as Edge; + + const makeInstance = (edges: Edge[] = [connectorEdge]) => + ({ getEdges: () => edges }) as unknown as ReactFlowInstance; + + const baseArgs = { + source: 'a', + sourceHandleId: SEQUENTIAL_BAR_HANDLE_IDS.source, + target: 'b', + targetHandleId: SEQUENTIAL_BAR_HANDLE_IDS.target, + sourcePosition: Position.Bottom, + position: { x: 100, y: 200 }, + }; + + it('rides the existing pipeline with bar-sized preview + ignored sequential types', () => { + const slot: InsertionSlot = { id: 'slot-1', graphEdgeId: 'e-1' }; + const options = buildSequentialPreviewOptions(makeInstance(), { + ...baseArgs, + slot, + connectorEdgeId: 'conn:e-1', + }); + + expect(options.previewNodeSize).toEqual({ width: SEQ_BAR_WIDTH, height: SEQ_BAR_HEIGHT }); + expect(options.ignoredNodeTypes).toBe(SEQUENTIAL_IGNORED_NODE_TYPES); + expect(options.positionMode).toBe('drop'); + expect(options.sourceHandleType).toBe('source'); + expect(options.handlePosition).toBe(Position.Bottom); + expect(options.position).toEqual({ x: 100, y: 200 }); + }); + + it('anchors preview edges on the slot canonical handle ids so connection filtering can resolve them', () => { + const slot: InsertionSlot = { + id: 'slot-1', + source: { nodeId: 'container', handleId: 'true' }, + target: { nodeId: 'b', handleId: 'input' }, + }; + const options = buildSequentialPreviewOptions(makeInstance(), { ...baseArgs, slot }); + // Node id and handle id both come from the slot: bars now render an + // invisible handle per manifest handle id (BaseNodeBar + // manifestSource/TargetHandleIds), so the preview edge renders AND + // usePreviewNode can resolve the existing node's handle manifest for the + // Add Node connection filter (bar handle ids never appeared in the manifest). + expect(options.source).toEqual({ nodeId: 'container', handleId: 'true' }); + expect(options.target).toEqual({ nodeId: 'b', handleId: 'input' }); + }); + + it('falls back to the default handle ids when the slot omits them (tail append)', () => { + const slot: InsertionSlot = { id: 'slot-tail', source: { nodeId: 'last' } }; + const options = buildSequentialPreviewOptions(makeInstance([]), { + ...baseArgs, + slot, + target: '', + targetHandleId: undefined, + }); + expect(options.source).toEqual({ nodeId: 'last', handleId: DEFAULT_SOURCE_HANDLE_ID }); + expect('target' in options).toBe(false); + }); + + it('drives a head insertion from the first canonical target without using the synthetic start row', () => { + const slot: InsertionSlot = { id: 'slot-head', target: { nodeId: 'first' } }; + const options = buildSequentialPreviewOptions(makeInstance(), { + ...baseArgs, + source: '__sequential-start__', + target: 'first', + slot, + }); + + expect(options.source).toEqual({ nodeId: 'first', handleId: DEFAULT_TARGET_HANDLE_ID }); + expect(options.sourceHandleType).toBe('target'); + expect(options.handlePosition).toBe(Position.Top); + expect('target' in options).toBe(false); + }); + + it('stashes the split CONNECTOR edge (found by its own rendered id) on data.originalEdge', () => { + const slot: InsertionSlot = { id: 'slot-1', graphEdgeId: 'e-1' }; + const options = buildSequentialPreviewOptions(makeInstance(), { + ...baseArgs, + slot, + connectorEdgeId: 'conn:e-1', + }); + expect(options.data).toEqual({ originalEdge: connectorEdge }); + }); + + it('never finds an edge via slot.graphEdgeId alone (different id space)', () => { + // Regression: slot.graphEdgeId ('e-1') is the CANONICAL edge id; the store + // here only holds the derived connector edge ('conn:e-1'). Omitting + // connectorEdgeId must not accidentally match by falling back to graphEdgeId. + const slot: InsertionSlot = { id: 'slot-1', graphEdgeId: 'e-1' }; + const options = buildSequentialPreviewOptions(makeInstance(), { ...baseArgs, slot }); + expect(options.data).toEqual({}); + }); + + it('omits originalEdge when the insert has no backing connector (tail append)', () => { + const slot: InsertionSlot = { id: 'slot-1' }; + const options = buildSequentialPreviewOptions(makeInstance(), { ...baseArgs, slot }); + expect(options.data).toEqual({}); + }); + + it('never forwards slot.containerId into the preview graph options', () => { + // createPreviewGraph would reparent the preview onto the container's + // rendered clone with extent:'parent'; in sequential view that clone is a + // flattened bar, so the preview would clamp onto the container's own row. + // Canonical containment is applied to the materialized node instead (see + // resolvePendingSequentialInsert / sequentialOnBeforeNodeAdded). + const withContainer = buildSequentialPreviewOptions(makeInstance(), { + ...baseArgs, + slot: { id: 'slot-1', containerId: 'loop-1' }, + }); + expect('containerId' in withContainer).toBe(false); + + const withoutContainer = buildSequentialPreviewOptions(makeInstance(), { + ...baseArgs, + slot: { id: 'slot-1' }, + }); + expect('containerId' in withoutContainer).toBe(false); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/sequentialInsert.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/sequentialInsert.ts new file mode 100644 index 000000000..4c3238b8a --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/sequentialInsert.ts @@ -0,0 +1,293 @@ +import type { + Edge, + Node, + ReactFlowInstance, + XYPosition, +} from '@uipath/apollo-react/canvas/xyflow/react'; +import { Position } from '@uipath/apollo-react/canvas/xyflow/react'; +import { + DEFAULT_SOURCE_HANDLE_ID, + DEFAULT_TARGET_HANDLE_ID, + SEQ_BAR_HEIGHT, + SEQ_BAR_WIDTH, + SEQ_PLACEHOLDER_NODE_TYPE, + SEQ_START_NODE_TYPE, +} from '../../../constants'; +import type { CreatePreviewGraphOptions } from '../../../utils/createPreviewGraph'; +import type { InsertionSlot } from '../../../utils/sequential/sequential.types'; +import { SEQUENTIAL_BAR_HANDLE_IDS } from '../nodes'; + +/** + * Node `type` values excluded from the Add Node collision passes + * (shiftForEdgeInsertion / resolveCollisions, utils/NodeUtils.ts) so they never + * reposition the layout-owned vertical stack (D5). + * + * IMPORTANT: this covers ONLY the synthetic rows. Real step clones KEEP their + * manifest node type, so `ignoredNodeTypes` cannot enumerate them — the + * robust guarantee that layout owns clone positions lives in the change + * filtering, which drops every position/dimension change for clones before + * forwarding onNodesChange to the consumer (seam 2). Listing the synthetic types + * here is defense in depth so the collision pass can't nudge the start bar or + * terminal placeholder either. + * + * The names are re-exported from constants.ts, the single source of truth shared + * with the node components (nodes/index.ts). + */ +export const SEQUENTIAL_IGNORED_NODE_TYPES: string[] = [ + SEQ_START_NODE_TYPE, + SEQ_PLACEHOLDER_NODE_TYPE, +]; + +/** Returns a fresh ignored-types array, optionally merged with host-specific extras. */ +export function getSequentialIgnoredNodeTypes(extra: readonly string[] = []): string[] { + return [...SEQUENTIAL_IGNORED_NODE_TYPES, ...extra]; +} + +/** Marker written on nodes inserted while in sequential view (D4). */ +export const SEQ_INSERTED_FLAG = 'seqInserted'; + +/** + * Marker stashed on a forwarded edge's `data` carrying the exact canonical edge + * id that an insert split, so `forwardSequentialEdgeChanges` can drop that edge + * precisely instead of inferring it. Stripped before the edge reaches the host. + */ +export const SEQ_SPLIT_EDGE_ID_KEY = '__sequentialSplitEdgeId'; + +/** Arguments the ⊕ button gives the insert wiring. */ +export interface SequentialInsertArgs { + /** The slot carried by the clicked connector. */ + slot: InsertionSlot; + /** Fallback source endpoint (the connector's own source), used when the slot omits one. */ + source: string; + /** + * The connector's own rendered source handle (always one of + * {@link SEQUENTIAL_BAR_HANDLE_IDS}). No longer consulted for handle-id + * resolution (see {@link buildSequentialPreviewOptions} and + * {@link resolvePendingSequentialInsert}) — propagating it into canonical + * state is exactly the bug those functions guard against. Kept on the arg + * shape for call-site stability. + */ + sourceHandleId?: string | null; + /** Fallback target endpoint (the connector's own target), used when the slot omits one. */ + target: string; + /** The connector's own rendered target handle. See {@link sourceHandleId}. */ + targetHandleId?: string | null; + /** Side of the source node the preview edge leaves from (bar bottom). */ + sourcePosition: Position; + /** Drop point for the preview, in flow coordinates (the connector midpoint). */ + position: XYPosition; + /** + * The id of the connector edge AS RENDERED (the `id` xyflow gives + * {@link SequentialConnectorEdge}), used to find and stash the split + * connector for cancel-restore. Undefined for slots with no backing edge + * (a tail append, or an empty branch/container body). + */ + connectorEdgeId?: string; +} + +/** + * Builds the {@link CreatePreviewGraphOptions} for a sequential insertion, + * riding the existing Add Node preview pipeline verbatim (D5). Splits into a + * pure builder so the option shape is unit-testable without a live canvas. + * + * The preview node is sized to a bar (SEQ_BAR_WIDTH x SEQ_BAR_HEIGHT) so the + * stack visibly opens a slot. Both preview edges anchor on + * {@link SEQUENTIAL_BAR_HANDLE_IDS} unconditionally: source/target in + * sequential view are always bar clones, which render only those two handles + * (BaseNodeBar), so any other handle id (a canonical manifest handle, or the + * generic DEFAULT_SOURCE_HANDLE_ID) fails to resolve and xyflow silently drops + * the edge. The canonical handle id the slot may carry is preserved + * separately — see {@link resolvePendingSequentialInsert} — and rewritten + * back onto the materialized edges in {@link sequentialOnBeforeNodeAdded}. + * + * The split connector (found by its OWN rendered id, `connectorEdgeId` — the + * store in sequential view holds derived connector edges, never canonical + * ids) is stashed on `data.originalEdge` so AddNodeManager can hide it while + * the panel is open and restore it if the panel is cancelled. + */ +export function buildSequentialPreviewOptions( + reactFlowInstance: ReactFlowInstance, + args: SequentialInsertArgs +): CreatePreviewGraphOptions { + const { slot, source, target, sourcePosition, position, connectorEdgeId } = args; + + const originalEdge = connectorEdgeId + ? reactFlowInstance.getEdges().find((edge) => edge.id === connectorEdgeId) + : undefined; + + // Any target-only slot has no canonical source node. Drive the same preview + // pipeline from its canonical target handle; sourceHandleType='target' makes + // the preview the upstream source. The Workflow-start connector is one user + // of this general slot shape, without making the synthetic start bar part of + // canonical state or registry constraint checks. + const targetEndpoint = slot.target; + const isTargetOnlyInsert = slot.source === undefined && targetEndpoint !== undefined; + const previewSourceNodeId = isTargetOnlyInsert + ? targetEndpoint.nodeId + : (slot.source?.nodeId ?? source); + const previewSourceHandleId = isTargetOnlyInsert + ? (targetEndpoint.handleId ?? DEFAULT_TARGET_HANDLE_ID) + : (slot.source?.handleId ?? DEFAULT_SOURCE_HANDLE_ID); + + // The target is optional: a tail append (the terminal placeholder) has a source + // but no downstream node. When neither the slot nor the fallback supplies a + // target node id, omit `target` entirely so the pipeline builds a source-only + // preview instead of wiring an edge to an empty id. + const targetNodeId = isTargetOnlyInsert ? undefined : (slot.target?.nodeId ?? target); + + return { + reactFlowInstance, + source: { + nodeId: previewSourceNodeId, + // Anchor on the slot's CANONICAL handle id (falling back to the default + // output), not the bar's generic seq-source. The bar now also renders an + // invisible handle with this canonical id (BaseNodeBar + // manifestSourceHandleIds), so the preview edge both renders AND lets + // usePreviewNode resolve the existing node's handle manifest for the Add + // Node connection filter. sequentialOnBeforeNodeAdded still normalizes the + // committed edge's handles. + handleId: previewSourceHandleId, + }, + ...(targetNodeId + ? { + target: { + nodeId: targetNodeId, + handleId: slot.target?.handleId ?? DEFAULT_TARGET_HANDLE_ID, + }, + } + : {}), + position, + positionMode: 'drop', + data: originalEdge ? { originalEdge } : {}, + sourceHandleType: isTargetOnlyInsert ? 'target' : 'source', + handlePosition: isTargetOnlyInsert ? Position.Top : sourcePosition, + ignoredNodeTypes: SEQUENTIAL_IGNORED_NODE_TYPES, + previewNodeSize: { width: SEQ_BAR_WIDTH, height: SEQ_BAR_HEIGHT }, + // containerId is intentionally NOT forwarded here: createPreviewGraph would + // reparent the preview onto the container's RENDERED clone with + // extent:'parent', but that clone is a flattened 896x72 bar (sequential + // view has no flow-view container body), so the preview would clamp to + // exactly overlap the container's row instead of showing in the lane. The + // canonical containment the slot describes is applied to the MATERIALIZED + // node instead — see resolvePendingSequentialInsert / sequentialOnBeforeNodeAdded. + }; +} + +/** Canonical containment/handle data resolved from a slot at insert time (D5). */ +export interface PendingSequentialInsert { + sourceNodeId?: string; + targetNodeId?: string; + graphEdgeId?: string; + /** slot.source's canonical handle id, if the split edge had an explicit one. */ + sourceHandleId?: string; + /** slot.target's canonical handle id, if the split edge had an explicit one. */ + targetHandleId?: string; + /** parentId for the materialized node, if the slot is scoped to a container. */ + containerId?: string; +} + +/** + * Captures the slot's canonical handle/containment data at the moment + * `startInsert` opens the panel, for {@link sequentialOnBeforeNodeAdded} to + * apply once the user picks a node (D5). Handle ids fall back to `undefined` + * (default-handle semantics), never to the connector's own bar handle — the + * bar handle is a view-rendering detail (see buildSequentialPreviewOptions) + * that must never reach canonical state. + */ +export function resolvePendingSequentialInsert(slot: InsertionSlot): PendingSequentialInsert { + return { + sourceNodeId: slot.source?.nodeId, + targetNodeId: slot.target?.nodeId, + graphEdgeId: slot.graphEdgeId, + sourceHandleId: slot.source?.handleId, + targetHandleId: slot.target?.handleId, + containerId: slot.containerId, + }; +} + +/** + * Adapter for `AddNodeManager.onBeforeNodeAdded` (D4/D5). It: + * 1. keeps the preview's position as a harmless placement hint — layout owns + * positions in sequential view and the projection recomputes them on the + * next structural change (D12), so any value here is transient; reusing + * the preview's position (instead of zeroing it) just avoids piling the + * node onto the head of the stack before the collision pass runs; + * 2. stamps `data.seqInserted = true` so `synthesizePositionsForFlow` can + * place the node when the user toggles back to flow view (D4). Does NOT + * stamp `draggable: false` — the sequential clone derivation already forces + * that unconditionally for every row (useSequentialGraph), so doing it here + * too would only leak into canonical state and permanently disable dragging + * once the user toggles back to flow view; + * 3. re-ids the node with `crypto.randomUUID()` and rewrites the new edges to + * reference the new id (the pipeline seeds a `type-Date.now()` id, which can + * collide within the same millisecond); + * 4. applies the slot's canonical containment (`parentId`/`extent`) and + * restores the two existing-node endpoints to the slot's exact canonical + * handles. The inserted node's endpoints are deliberately left alone: + * AddNodeManager resolves those from the inserted node's own manifest. + * `pending` is captured by `useSequentialInsert` from the slot at the + * moment the panel opened (see resolvePendingSequentialInsert). + */ +export function sequentialOnBeforeNodeAdded( + newNode: Node, + newEdges: Edge[], + pending?: PendingSequentialInsert +): { newNode: Node; newEdges: Edge[] } { + const seqId = crypto.randomUUID(); + const previousId = newNode.id; + + const finalNode: Node = { + ...newNode, + id: seqId, + ...(pending?.containerId ? { parentId: pending.containerId, extent: 'parent' as const } : {}), + data: { ...newNode.data, [SEQ_INSERTED_FLAG]: true }, + }; + + const finalEdges = newEdges.map((edge) => { + const nextSource = edge.source === previousId ? seqId : edge.source; + const nextTarget = edge.target === previousId ? seqId : edge.target; + let nextSourceHandle = edge.sourceHandle; + let nextTargetHandle = edge.targetHandle; + // Preview edges use concrete handles so registry filtering works, while + // the split canonical edge may have relied on implicit defaults. Restore + // only the EXISTING nodes' exact endpoint semantics before forwarding; + // the inserted node's handles already came from its own manifest. + if ( + pending?.sourceNodeId && + edge.source === pending.sourceNodeId && + edge.target === previousId + ) { + nextSourceHandle = pending.sourceHandleId; + } + if ( + pending?.targetNodeId && + edge.source === previousId && + edge.target === pending.targetNodeId + ) { + nextTargetHandle = pending.targetHandleId; + } + const data = pending?.graphEdgeId + ? { ...edge.data, [SEQ_SPLIT_EDGE_ID_KEY]: pending.graphEdgeId } + : edge.data; + if ( + nextSource === edge.source && + nextTarget === edge.target && + nextSourceHandle === edge.sourceHandle && + nextTargetHandle === edge.targetHandle && + data === edge.data + ) { + return edge; + } + return { + ...edge, + source: nextSource, + target: nextTarget, + sourceHandle: nextSourceHandle, + targetHandle: nextTargetHandle, + data, + id: `edge_${nextSource}-${nextSourceHandle}-${nextTarget}-${nextTargetHandle}`, + }; + }); + + return { newNode: finalNode, newEdges: finalEdges }; +} diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/useSequentialInsert.test.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/useSequentialInsert.test.tsx new file mode 100644 index 000000000..2fb095ac3 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/useSequentialInsert.test.tsx @@ -0,0 +1,106 @@ +import { renderHook } from '@testing-library/react'; +import type { Node } from '@uipath/apollo-react/canvas/xyflow/react'; +import { Position } from '@uipath/apollo-react/canvas/xyflow/react'; +import type { ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import type { InsertionSlot } from '../../../utils/sequential/sequential.types'; +import { SequentialInsertGapProvider } from '../SequentialInsertGapContext'; +import { SequentialInsertStateProvider } from '../SequentialInsertStateContext'; +import { useSequentialInsert } from './useSequentialInsert'; + +// The preview pipeline needs a live RF store; this hook test only exercises the +// pending-slot bookkeeping, so stub the side effect out. +vi.mock('../../../utils/createPreviewGraph', async (importOriginal) => ({ + ...(await importOriginal()), + showPreviewGraph: vi.fn(), +})); + +describe('useSequentialInsert', () => { + it('publishes the clicked slot so the placeholder is swapped for the preview row', () => { + const setGapSlot = vi.fn(); + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + const hook = renderHook(() => useSequentialInsert(), { wrapper }); + const slot: InsertionSlot = { id: 'tail', source: { nodeId: 'last-step' } }; + + hook.result.current.startInsert({ + slot, + source: 'last-step', + target: '', + sourcePosition: Position.Bottom, + position: { x: 0, y: 72 }, + }); + + expect(setGapSlot).toHaveBeenCalledWith(slot); + }); + + it('shares the pending slot across separate hook instances so container parenting survives commit', () => { + // Regression: the connector plus (startInsert) and AddNodeManager's + // onBeforeNodeAdded are DIFFERENT useSequentialInsert instances. A + // per-instance ref left the commit side blind to the slot's containerId, so + // inserts into a For Each body / branch lane dropped parentId and orphaned + // the node. Both hook instances share one per-canvas provider. + const hooks = renderHook( + () => ({ opener: useSequentialInsert(), committer: useSequentialInsert() }), + { wrapper: SequentialInsertStateProvider } + ); + + const slot: InsertionSlot = { + id: 'slot-then', + source: { nodeId: 'if', handleId: 'true' }, + target: { nodeId: 'javascript-1', handleId: 'input' }, + graphEdgeId: 'e-if-then', + containerId: 'for-each', + }; + + hooks.result.current.opener.startInsert({ + slot, + source: 'if', + target: 'javascript-1', + sourcePosition: Position.Bottom, + position: { x: 0, y: 0 }, + }); + + const previewNode: Node = { + id: 'preview-node-id', + type: 'uipath.blank-node', + position: { x: 0, y: 0 }, + data: {}, + }; + const { newNode } = hooks.result.current.committer.onBeforeNodeAdded(previewNode, []); + + // The other instance's commit sees the opener's slot: container parent is + // stamped so the node splices into the lane rather than orphaning at the end. + expect(newNode.parentId).toBe('for-each'); + expect(newNode.extent).toBe('parent'); + }); + + it('isolates pending inserts between canvas providers', () => { + const canvasA = renderHook(() => useSequentialInsert(), { + wrapper: SequentialInsertStateProvider, + }); + const canvasB = renderHook(() => useSequentialInsert(), { + wrapper: SequentialInsertStateProvider, + }); + canvasA.result.current.startInsert({ + slot: { id: 'a', source: { nodeId: 'a' }, containerId: 'container-a' }, + source: 'a', + target: '', + sourcePosition: Position.Bottom, + position: { x: 0, y: 0 }, + }); + canvasB.result.current.startInsert({ + slot: { id: 'b', source: { nodeId: 'b' }, containerId: 'container-b' }, + source: 'b', + target: '', + sourcePosition: Position.Bottom, + position: { x: 0, y: 0 }, + }); + const seed: Node = { id: 'preview-node-id', position: { x: 0, y: 0 }, data: {} }; + expect(canvasA.result.current.onBeforeNodeAdded(seed, []).newNode.parentId).toBe('container-a'); + expect(canvasB.result.current.onBeforeNodeAdded(seed, []).newNode.parentId).toBe('container-b'); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/useSequentialInsert.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/useSequentialInsert.ts new file mode 100644 index 000000000..e21208c76 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/useSequentialInsert.ts @@ -0,0 +1,68 @@ +import type { Edge, Node } from '@uipath/apollo-react/canvas/xyflow/react'; +import { useReactFlow } from '@uipath/apollo-react/canvas/xyflow/react'; +import { useCallback, useRef } from 'react'; +import { showPreviewGraph } from '../../../utils/createPreviewGraph'; +import { useSetSequentialInsertGapSlot } from '../SequentialInsertGapContext'; +import { useOptionalSequentialInsertState } from '../SequentialInsertStateContext'; +import { + buildSequentialPreviewOptions, + resolvePendingSequentialInsert, + type SequentialInsertArgs, + sequentialOnBeforeNodeAdded, +} from './sequentialInsert'; + +export interface UseSequentialInsertResult { + /** + * Opens the Add Node panel for a connector's insertion slot by riding the + * existing preview pipeline (D5). Call this from the ⊕ button's click handler. + */ + startInsert: (args: SequentialInsertArgs) => void; + /** + * Bound `AddNodeManager.onBeforeNodeAdded` adapter. Closes over the slot + * captured by the most recent `startInsert` call so materialization can + * apply the slot's canonical handle ids / containment (D5) — `onBeforeNodeAdded` + * itself only receives the new node and edges, not the slot that started the + * insert, so this hook is the side channel between the two. + */ + onBeforeNodeAdded: (newNode: Node, newEdges: Edge[]) => { newNode: Node; newEdges: Edge[] }; +} + +/** + * Wires the ⊕ insert affordance to the existing Add Node preview pipeline. It + * only kicks off the preview; the panel, constraint filtering, materialization, + * and cancel-restore all run through the unchanged AddNodeManager (which the + * assembly mounts as a child of the canvas with `onBeforeNodeAdded` from this hook and + * `ignoredNodeTypes` from ./sequentialInsert). + */ +export function useSequentialInsert(): UseSequentialInsertResult { + const reactFlowInstance = useReactFlow(); + const setInsertGapSlot = useSetSequentialInsertGapSlot(); + const localPending = useRef | undefined>( + undefined + ); + const sharedState = useOptionalSequentialInsertState(); + const pending = sharedState?.pending ?? localPending; + + const startInsert = useCallback( + (args: SequentialInsertArgs) => { + pending.current = resolvePendingSequentialInsert(args.slot); + // Surfaces the active slot for the insert-preview-gap post-pass: + // SequentialCanvas.tsx owns the state and clears it again + // on cancel/commit (usePreviewNode transitioning closed). + setInsertGapSlot(args.slot); + showPreviewGraph(buildSequentialPreviewOptions(reactFlowInstance, args)); + }, + [reactFlowInstance, setInsertGapSlot, pending] + ); + + const onBeforeNodeAdded = useCallback( + (newNode: Node, newEdges: Edge[]) => { + const result = sequentialOnBeforeNodeAdded(newNode, newEdges, pending.current); + pending.current = undefined; + return result; + }, + [pending] + ); + + return { startInsert, onBeforeNodeAdded }; +} diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/index.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/index.ts new file mode 100644 index 000000000..173d34625 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/index.ts @@ -0,0 +1,18 @@ +export { SequentialCanvas } from './SequentialCanvas'; +export type { SequentialCanvasProps, ViewSwitcherProps } from './SequentialCanvas.types'; +export type { + SequentialViewContextValue, + SequentialViewProviderProps, +} from './SequentialViewContext'; +export { SequentialViewProvider, useSequentialView } from './SequentialViewContext'; +export { synthesizePositionsForFlow } from './synthesizePositionsForFlow'; +export { useCanvasViewMode } from './useCanvasViewMode'; +export type { SequentialGraph, UseSequentialGraphArgs } from './useSequentialGraph'; +export { deriveSequentialGraph, useSequentialGraph } from './useSequentialGraph'; +export type { + SequentialKeyboardRow, + UseSequentialKeyboardArgs, + UseSequentialKeyboardResult, +} from './useSequentialKeyboard'; +export { useSequentialKeyboard } from './useSequentialKeyboard'; +export { ViewSwitcher } from './ViewSwitcher'; diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/BarVariant.stories.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/BarVariant.stories.tsx new file mode 100644 index 000000000..3dba72d9e --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/BarVariant.stories.tsx @@ -0,0 +1,123 @@ +/** + * Sequential bar variant, visual-drift contract. + * + * Renders the same representative manifests twice: as free-form cards (left) and + * as sequential bars (right), plus the synthetic start and placeholder rows. The + * bar reuses BaseNode's resolved display, so icon, label, and colors match the + * card for every node. This is an early seed of the fuller card/bar contract. + */ + +import type { Meta, StoryObj } from '@storybook/react'; +import type { Node, NodeTypes } from '@uipath/apollo-react/canvas/xyflow/react'; +import { ReactFlowProvider } from '@uipath/apollo-react/canvas/xyflow/react'; +import { useMemo } from 'react'; +import { useNodeTypeRegistry } from '../../../core'; +import { + createNode, + useNodeTypesFromRegistry, + withCanvasProviders, +} from '../../../storybook-utils'; +import { BaseCanvas } from '../../BaseCanvas'; +import { BaseNode } from '../../BaseNode/BaseNode'; +import { + SEQ_PLACEHOLDER_NODE_TYPE, + SEQ_START_NODE_TYPE, + SEQUENTIAL_SYNTHETIC_NODE_TYPES, + SequentialStepNode, +} from './index'; + +const meta: Meta = { + title: 'Components/Nodes/Sequential Bar', + parameters: { layout: 'fullscreen' }, + decorators: [withCanvasProviders()], +}; + +export default meta; +type Story = StoryObj; + +// Representative manifests: a trigger, a branch, a container, a data step, and an +// agent. All exist in the default workflow manifest installed by the decorator. +const REPRESENTATIVE_TYPES = [ + 'uipath.manual-trigger', + 'uipath.control-flow.decision', + 'uipath.control-flow.foreach', + 'uipath.data.transform', + 'uipath.agent', +] as const; + +const CARD_PITCH = 176; +const BAR_PITCH = 128; + +function cardNodes(): Node[] { + return REPRESENTATIVE_TYPES.map((type, i) => + createNode({ id: `card-${i}`, type, position: { x: 0, y: i * CARD_PITCH } }) + ); +} + +function barNodes(): Node[] { + const start: Node = { + id: 'seq-start', + type: SEQ_START_NODE_TYPE, + position: { x: 0, y: 0 }, + data: { onAddTrigger: () => {} }, + draggable: false, + }; + const steps = REPRESENTATIVE_TYPES.map((type, i) => + createNode({ id: `bar-${i}`, type, position: { x: 0, y: (i + 1) * BAR_PITCH } }) + ); + const placeholder: Node = { + id: 'seq-placeholder', + type: SEQ_PLACEHOLDER_NODE_TYPE, + position: { x: 0, y: (REPRESENTATIVE_TYPES.length + 1) * BAR_PITCH }, + data: { onAdd: () => {} }, + draggable: false, + }; + return [start, ...steps, placeholder]; +} + +function BarVariantStory() { + const registry = useNodeTypeRegistry(); + const cardTypes = useNodeTypesFromRegistry(BaseNode); + const barTypes = useMemo(() => { + const types: NodeTypes = { default: SequentialStepNode, ...SEQUENTIAL_SYNTHETIC_NODE_TYPES }; + for (const manifest of registry.getAllManifests()) { + types[manifest.nodeType] = SequentialStepNode; + } + return types; + }, [registry]); + + const cards = useMemo(cardNodes, []); + const bars = useMemo(barNodes, []); + + return ( +
+
+ + + +
+
+ + + +
+
+ ); +} + +export const CardVsBar: Story = { + name: 'Card vs Bar', + render: () => , +}; diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialInsertPreviewNode.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialInsertPreviewNode.tsx new file mode 100644 index 000000000..e5958a88c --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialInsertPreviewNode.tsx @@ -0,0 +1,75 @@ +import type { NodeProps } from '@uipath/apollo-react/canvas/xyflow/react'; +import { Handle, Position } from '@uipath/apollo-react/canvas/xyflow/react'; +import { cn } from '@uipath/apollo-wind'; +import { memo, useMemo } from 'react'; +import { useSafeLingui } from '../../../../i18n'; +import { getIcon } from '../../../utils/icon-registry'; +import { + getSeqBarVars, + INVISIBLE_HANDLE_STYLE, + SEQ_BAR_SHELL_CLASS, + SEQUENTIAL_BAR_HANDLE_IDS, +} from '../../BaseNode/BaseNodeBar'; +import { BaseInnerShape } from '../../BaseNode/BaseNodeInnerShape'; + +/** + * Sequential-view Add Node preview bar. The shared {@link AddNodePreview} + * centers a `min(w,h)`-sized glyph, which in the 896x56 bar leaves a nearly + * empty slab with a lonely icon. This mirrors the real bar layout instead: a + * dashed, de-emphasized bar with the icon box on the LEFT and a "New step" + * label, so the slot the insert will fill reads like a ghost of a real row. + * + * Registered as the `preview` node type only in the sequential view (see + * SequentialCanvas.tsx); the flow view keeps AddNodePreview. Its handle ids + * (`input`/`output`) match the Add Node preview edges, offset to the connector + * spine (SEQ handle offset) so those edges align like a real bar's connectors. + */ +function SequentialInsertPreviewNodeComponent({ width, height }: NodeProps) { + const { _ } = useSafeLingui(); + const label = _({ id: 'sequential-canvas.new-step', message: 'New step' }); + const icon = useMemo(() => { + const IconComponent = getIcon('square-dashed'); + return ; + }, []); + + return ( +
+ + + {icon} + {label} + +
+ ); +} + +export const SequentialInsertPreviewNode = memo(SequentialInsertPreviewNodeComponent); diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialPlaceholderNode.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialPlaceholderNode.tsx new file mode 100644 index 000000000..4220b6519 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialPlaceholderNode.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 { 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'; + +export interface SequentialPlaceholderNodeData extends Record { + /** Invoked when the placeholder row is clicked to add a trailing step. */ + onAdd?: () => void; + /** View-only slot identity used to recover this row's position when clicked. */ + insertionSlotId?: string; +} + +/** + * Synthetic terminal "add a step" row. A dashed, bar-sized drop target styled + * after AddNodePreview with a centered plus. It carries only a top target handle + * (it follows the last real step). Injected view-only and filtered out of the + * change callbacks by the canvas assembly. + */ +function SequentialPlaceholderNodeComponent({ + data, + width, + height, +}: NodeProps>) { + const { _ } = useSafeLingui(); + const onAdd = data?.onAdd; + const isDesignMode = useBaseCanvasMode().mode === 'design'; + const label = _({ id: 'sequential-canvas.placeholder.add', message: 'Add step' }); + + return ( + + ); +} + +export const SequentialPlaceholderNode = memo(SequentialPlaceholderNodeComponent); diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialStartNode.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialStartNode.tsx new file mode 100644 index 000000000..98eb3eeea --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialStartNode.tsx @@ -0,0 +1,77 @@ +import type { Node, NodeProps } from '@uipath/apollo-react/canvas/xyflow/react'; +import { Handle, Position } from '@uipath/apollo-react/canvas/xyflow/react'; +import { Button, cn } from '@uipath/apollo-wind'; +import { memo } from 'react'; +import { useSafeLingui } from '../../../../i18n'; +import { CanvasIcon } from '../../../utils/icon-registry'; +import { useBaseCanvasMode } from '../../BaseCanvas/BaseCanvasModeProvider'; +import { + getSeqBarVars, + INVISIBLE_HANDLE_STYLE, + SEQ_BAR_SHELL_CLASS, + SEQUENTIAL_BAR_HANDLE_IDS, +} from '../../BaseNode/BaseNodeBar'; +import { BaseInnerShape } from '../../BaseNode/BaseNodeInnerShape'; + +export interface SequentialStartNodeData extends Record { + /** Invoked by the "Add trigger" button. Injected view-side by the canvas. */ + onAddTrigger?: () => void; +} + +/** + * Synthetic first row of the sequential view. A bar-sized shell with a play icon + * and a right-aligned "Add trigger" call to action. It carries only a bottom + * source handle (nothing precedes the start). This node is injected view-only + * and filtered out of the change callbacks by the canvas assembly. + */ +function SequentialStartNodeComponent({ + data, + width, + height, +}: NodeProps>) { + const { _ } = useSafeLingui(); + const onAddTrigger = data?.onAddTrigger; + const isDesignMode = useBaseCanvasMode().mode === 'design'; + + return ( +
+
+ + + +
+ + + {_({ id: 'sequential-canvas.start.title', message: 'Workflow start' })} + + + {isDesignMode && onAddTrigger && ( + + )} + + +
+ ); +} + +export const SequentialStartNode = memo(SequentialStartNodeComponent); diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialStepNode.test.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialStepNode.test.tsx new file mode 100644 index 000000000..970e4398f --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialStepNode.test.tsx @@ -0,0 +1,29 @@ +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 BaseNode's bar variant needs. +vi.mock('../../BaseNode/BaseNode', () => ({ + BaseNode: ({ renderVariant }: { renderVariant?: string }) => ( +
+ ), +})); + +// 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 BaseNode bar; branch insertion lives in placeholder rows', () => { + render( + + + + ); + + expect(screen.getByTestId('base-node')).toHaveAttribute('data-render-variant', 'bar'); + 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..33a198431 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialStepNode.tsx @@ -0,0 +1,52 @@ +import type { Node, NodeProps } from '@uipath/apollo-react/canvas/xyflow/react'; +import { memo } from 'react'; +import { areNodePropsEqualIgnoringPosition } from '../../../utils/nodePropsEqual'; +import { BaseNode } from '../../BaseNode/BaseNode'; +import type { BaseNodeData } from '../../BaseNode/BaseNode.types'; +import { useSequentialCollapsedRows } from '../SequentialCollapsedRowsContext'; +import { useSequentialMoveMenuItems } from './useSequentialMoveMenuItems'; + +/** + * Sequential Canvas step row. A thin wrapper that renders the shared BaseNode in + * its bar variant, so a node's icon, label, colors, execution/validation status, + * and toolbar are single-sourced from BaseNode and identical to the flow view. + * + * Register this for every real manifest node type in the sequential nodeTypes + * map (the node keeps its real `type`, so BaseNode resolves the real manifest). + * + * Reads the view-local collapsed row-id set from context rather than + * `node.data`, so a collapsed collapsible row's bar renders BaseContainer's + * `isStacked` treatment without mutating the sequential clone's data (D12). + * `React.memo`'s prop comparator only gates re-renders from prop changes; + * context updates always re-render consuming descendants regardless, so this + * stays correctly reactive to collapse toggles under + * `areNodePropsEqualIgnoringPosition`. + * + * Also computes the four explorer-like tree-move kebab items from + * `SequentialMoveActionsContext` via `useSequentialMoveMenuItems`, + * passed through `BaseNode.extraMenuItems` (D3). Same context-updates-always- + * re-render reasoning applies: a projection change (which changes which + * moves are available) re-renders this node even though it's memoized. + * + * Appendable branch tails are followed by the same full-width dashed + * SequentialPlaceholderNode used for empty branches, so this wrapper contains + * no separate leaf-specific add affordance. + */ +function SequentialStepNodeComponent(props: NodeProps>) { + const collapsedRowIds = useSequentialCollapsedRows(); + const extraMenuItems = useSequentialMoveMenuItems(props.id); + + return ( + + ); +} + +export const SequentialStepNode = memo( + SequentialStepNodeComponent, + areNodePropsEqualIgnoringPosition +); diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/index.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/index.ts new file mode 100644 index 000000000..9a9a33741 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/index.ts @@ -0,0 +1,40 @@ +import type { NodeTypes } from '@uipath/apollo-react/canvas/xyflow/react'; +import { SEQ_PLACEHOLDER_NODE_TYPE, SEQ_START_NODE_TYPE } from '../../../constants'; +import { SequentialPlaceholderNode } from './SequentialPlaceholderNode'; +import { SequentialStartNode } from './SequentialStartNode'; +import { SequentialStepNode } from './SequentialStepNode'; + +// Handle ids every bar exposes (re-exported for the connector/insert pipeline). +export { + INVISIBLE_HANDLE_STYLE, + SEQUENTIAL_BAR_HANDLE_IDS, +} from '../../BaseNode/BaseNodeBar'; +export { SequentialInsertPreviewNode } from './SequentialInsertPreviewNode'; +export { + SequentialPlaceholderNode, + type SequentialPlaceholderNodeData, +} from './SequentialPlaceholderNode'; +export { + SequentialStartNode, + type SequentialStartNodeData, +} from './SequentialStartNode'; +export { SequentialStepNode } from './SequentialStepNode'; + +/** + * Synthetic node type keys for the start and placeholder rows. The canvas + * the assembly stamps injected nodes with these `type` values and merges + * these into the nodeTypes map; every real manifest type maps to + * {@link SequentialStepNode}. Defined in constants.ts so the insert pipeline + * (edges/sequentialInsert.ts) shares the exact same source of truth. + */ +export { SEQ_PLACEHOLDER_NODE_TYPE, SEQ_START_NODE_TYPE }; + +/** + * Convenience nodeTypes entries for the synthetic rows. Spread these into the + * per-view nodeTypes map alongside the registry types mapped to + * {@link SequentialStepNode}. + */ +export const SEQUENTIAL_SYNTHETIC_NODE_TYPES: NodeTypes = { + [SEQ_START_NODE_TYPE]: SequentialStartNode, + [SEQ_PLACEHOLDER_NODE_TYPE]: SequentialPlaceholderNode, +}; diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/useSequentialMoveMenuItems.test.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/useSequentialMoveMenuItems.test.tsx new file mode 100644 index 000000000..fd8d94e9c --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/useSequentialMoveMenuItems.test.tsx @@ -0,0 +1,119 @@ +import { renderHook } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { BaseCanvasModeProvider } from '../../BaseCanvas/BaseCanvasModeProvider'; +import type { NodeMenuAction } from '../../NodeContextMenu'; +import { + type SequentialMoveActionsContextValue, + SequentialMoveActionsProvider, +} from '../SequentialMoveActionsContext'; +import type { SequentialMoveOptions } from '../sequentialMoveActions'; +import { useSequentialMoveMenuItems } from './useSequentialMoveMenuItems'; + +function makeMoveActions( + overrides: Partial = {} +): SequentialMoveActionsContextValue { + return { + getMoveOptions: () => ({ + up: undefined, + down: undefined, + indent: undefined, + outdent: undefined, + }), + commitMove: vi.fn(), + centerOnNode: vi.fn(), + ...overrides, + }; +} + +function wrapper( + mode: 'design' | 'view' | 'readonly', + moveActions?: SequentialMoveActionsContextValue +) { + return ({ children }: { children: ReactNode }) => ( + + {moveActions ? ( + + {children} + + ) : ( + children + )} + + ); +} + +describe('useSequentialMoveMenuItems', () => { + it('returns an empty array outside a SequentialMoveActionsContext provider', () => { + const { result } = renderHook(() => useSequentialMoveMenuItems('a'), { + wrapper: wrapper('design'), + }); + expect(result.current).toEqual([]); + }); + + it('returns an empty array outside design mode, even with a provider', () => { + const moveActions = makeMoveActions(); + const { result } = renderHook(() => useSequentialMoveMenuItems('a'), { + wrapper: wrapper('view', moveActions), + }); + expect(result.current).toEqual([]); + }); + + it('builds four items with localized labels, in design mode with a provider', () => { + const moveActions = makeMoveActions(); + const { result } = renderHook(() => useSequentialMoveMenuItems('a'), { + wrapper: wrapper('design', moveActions), + }); + + expect(result.current).toHaveLength(4); + const labels = (result.current as NodeMenuAction[]).map((item) => item.label); + expect(labels).toEqual(['Move up', 'Move down', 'Move into previous step', 'Move out']); + }); + + it('disables an item when its direction has no slot, and enables it otherwise', () => { + const options: SequentialMoveOptions = { + up: { id: 'up-slot', source: { nodeId: 'prev' } }, + down: undefined, + indent: undefined, + outdent: undefined, + }; + const moveActions = makeMoveActions({ getMoveOptions: () => options }); + const { result } = renderHook(() => useSequentialMoveMenuItems('a'), { + wrapper: wrapper('design', moveActions), + }); + + const items = result.current as NodeMenuAction[]; + expect(items.find((item) => item.label === 'Move up')?.disabled).toBe(false); + expect(items.find((item) => item.label === 'Move down')?.disabled).toBe(true); + }); + + it('clicking an enabled item calls commitMove with the node id and the resolved slot', () => { + const slot = { id: 'up-slot', source: { nodeId: 'prev' } }; + const commitMove = vi.fn(); + const moveActions = makeMoveActions({ + getMoveOptions: () => ({ up: slot, down: undefined, indent: undefined, outdent: undefined }), + commitMove, + }); + const { result } = renderHook(() => useSequentialMoveMenuItems('node-a'), { + wrapper: wrapper('design', moveActions), + }); + + const upItem = (result.current as NodeMenuAction[]).find((item) => item.label === 'Move up')!; + upItem.onClick(); + expect(commitMove).toHaveBeenCalledWith('node-a', slot); + }); + + it('clicking a disabled item does not call commitMove', () => { + const commitMove = vi.fn(); + const moveActions = makeMoveActions({ commitMove }); + const { result } = renderHook(() => useSequentialMoveMenuItems('node-a'), { + wrapper: wrapper('design', moveActions), + }); + + const downItem = (result.current as NodeMenuAction[]).find( + (item) => item.label === 'Move down' + )!; + downItem.onClick(); + expect(commitMove).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/useSequentialMoveMenuItems.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/useSequentialMoveMenuItems.tsx new file mode 100644 index 000000000..945bd4550 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/useSequentialMoveMenuItems.tsx @@ -0,0 +1,75 @@ +import { useMemo } from 'react'; +import { useSafeLingui } from '../../../../i18n'; +import { CanvasIcon } from '../../../utils/icon-registry'; +import { useBaseCanvasMode } from '../../BaseCanvas/BaseCanvasModeProvider'; +import type { NodeMenuAction, NodeMenuItem } from '../../NodeContextMenu'; +import { useOptionalSequentialMoveActions } from '../SequentialMoveActionsContext'; + +/** + * Builds the four kebab "explorer-like tree move" items (Move up, Move down, + * Move into previous step, Move out) for a Sequential Canvas step row, backed + * by the engine's binding contract + * (utils/sequential/mutations.ts, slotNavigation.ts). Consumed by + * `SequentialStepNode` and passed to `BaseNode`'s `extraMenuItems` (D3: no new + * `BaseNodeOverrideConfig` field -- a direct component prop instead). + * + * Disabled state comes straight from `SequentialMoveActionsContext.getMoveOptions` + * (see `sequentialMoveActions.ts` for the disable logic, including the + * bare-branch-owner gate extended to all four directions). Move actions are a + * TOPOLOGY mutation (D4), so -- like the ⊕ insert affordance + * (`SequentialConnectorEdge`'s `showInsert`) -- they only appear in design + * mode; outside a `SequentialMoveActionsContext` provider (isolated node + * stories/tests) this returns an empty array rather than throwing. + */ +export function useSequentialMoveMenuItems(nodeId: string): NodeMenuItem[] { + const { _ } = useSafeLingui(); + const isDesignMode = useBaseCanvasMode().mode === 'design'; + const moveActions = useOptionalSequentialMoveActions(); + + return useMemo(() => { + if (!isDesignMode || !moveActions) return []; + const options = moveActions.getMoveOptions(nodeId); + + const item = ( + id: string, + label: string, + icon: string, + slot: (typeof options)['up'] + ): NodeMenuAction => ({ + id, + label, + icon: , + disabled: !slot, + onClick: () => { + if (slot) moveActions.commitMove(nodeId, slot); + }, + }); + + return [ + item( + 'move-up', + _({ id: 'sequential-canvas.move.up', message: 'Move up' }), + 'arrow-up', + options.up + ), + item( + 'move-down', + _({ id: 'sequential-canvas.move.down', message: 'Move down' }), + 'arrow-down', + options.down + ), + item( + 'move-indent', + _({ id: 'sequential-canvas.move.indent', message: 'Move into previous step' }), + 'indent', + options.indent + ), + item( + 'move-outdent', + _({ id: 'sequential-canvas.move.outdent', message: 'Move out' }), + 'outdent', + options.outdent + ), + ]; + }, [isDesignMode, moveActions, nodeId, _]); +} diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialChangeFilters.test.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialChangeFilters.test.ts new file mode 100644 index 000000000..181f9940e --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialChangeFilters.test.ts @@ -0,0 +1,262 @@ +import type { Edge, EdgeChange, Node, NodeChange } from '@uipath/apollo-react/canvas/xyflow/react'; +import { describe, expect, it } from 'vitest'; +import { PREVIEW_EDGE_ID, PREVIEW_NODE_ID } from '../../constants'; +import type { GraphChangeSet } from '../../utils/sequential/sequential.types'; +import { SEQ_INSERTED_FLAG } from './edges/sequentialInsert'; +import { + forwardSequentialEdgeChanges, + forwardSequentialNodeChanges, + graphChangeSetToEdgeChanges, + graphChangeSetToNodeChanges, +} from './sequentialChangeFilters'; +import { SEQ_CONNECTOR_EDGE_TYPE, SEQ_START_ROW_ID } from './sequentialGraph.constants'; + +const SYNTHETIC = new Set([SEQ_START_ROW_ID]); + +function canonical(id: string, position = { x: 100, y: 100 }): Node { + return { id, type: 'uipath.script', position, width: 288, data: { display: { label: id } } }; +} + +describe('forwardSequentialNodeChanges', () => { + const canonicalById = new Map([['a', canonical('a')]]); + + it('drops position and dimension changes (the derivation owns geometry)', () => { + const changes: NodeChange[] = [ + { type: 'position', id: 'a', position: { x: 9, y: 9 } }, + { type: 'dimensions', id: 'a', dimensions: { width: 896, height: 72 } }, + ]; + expect(forwardSequentialNodeChanges(changes, SYNTHETIC, canonicalById)).toEqual([]); + }); + + it('drops changes referencing synthetic rows and the preview node', () => { + const changes: NodeChange[] = [ + { type: 'select', id: SEQ_START_ROW_ID, selected: true }, + { type: 'select', id: PREVIEW_NODE_ID, selected: true }, + { type: 'remove', id: PREVIEW_NODE_ID }, + ]; + expect(forwardSequentialNodeChanges(changes, SYNTHETIC, canonicalById)).toEqual([]); + }); + + it('drops changes referencing synthetic empty-lane placeholder nodes', () => { + const synthetic = new Set([ + ...SYNTHETIC, + '__sequential-lane__if::true', + '__sequential-lane__loop::start', + ]); + const changes: NodeChange[] = [ + { type: 'select', id: '__sequential-lane__if::true', selected: true }, + { type: 'remove', id: '__sequential-lane__loop::start' }, + ]; + expect(forwardSequentialNodeChanges(changes, synthetic, canonicalById)).toEqual([]); + }); + + it('does not drop a canonical node merely because its id resembles a synthetic id', () => { + const id = '__sequential-lane__consumer-owned'; + const changes: NodeChange[] = [{ type: 'select', id, selected: true }]; + + expect(forwardSequentialNodeChanges(changes, SYNTHETIC, canonicalById)).toEqual(changes); + }); + + it('passes select and remove of real nodes through', () => { + const changes: NodeChange[] = [ + { type: 'select', id: 'a', selected: true }, + { type: 'remove', id: 'a' }, + ]; + expect(forwardSequentialNodeChanges(changes, SYNTHETIC, canonicalById)).toEqual(changes); + }); + + it('rewrites a rename replace to merge data onto canonical, preserving geometry', () => { + const cloneItem: Node = { + id: 'a', + type: 'uipath.script', + position: { x: 0, y: 5000 }, // clone/view position + width: 896, // bar width + selected: true, + data: { display: { label: 'renamed' } }, + }; + const changes: NodeChange[] = [{ type: 'replace', id: 'a', item: cloneItem }]; + + const [out] = forwardSequentialNodeChanges(changes, SYNTHETIC, canonicalById); + expect(out?.type).toBe('replace'); + const item = (out as { item: Node }).item; + // Canonical geometry preserved, only data + selection merged. + expect(item.position).toEqual({ x: 100, y: 100 }); + expect(item.width).toBe(288); + expect(item.selected).toBe(true); + expect((item.data as { display: { label: string } }).display.label).toBe('renamed'); + }); + + it('forwards an inserted node add, drops non-inserted adds', () => { + const insertedItem: Node = { + id: 'new', + type: 'uipath.slack', + position: { x: 0, y: 0 }, + data: { [SEQ_INSERTED_FLAG]: true }, + }; + const changes: NodeChange[] = [ + { type: 'add', item: insertedItem }, + { type: 'add', item: canonical('other') }, + ]; + const out = forwardSequentialNodeChanges(changes, SYNTHETIC, canonicalById); + expect(out).toHaveLength(1); + expect((out[0] as { item: Node }).item.id).toBe('new'); + }); +}); + +describe('forwardSequentialEdgeChanges', () => { + const canonicalEdges: Edge[] = [ + { id: 'e-st', source: 's', target: 't' }, + { id: 'e-other', source: 'x', target: 'y' }, + ]; + const canonicalEdgeIds = new Set(canonicalEdges.map((edge) => edge.id)); + + it('drops connector and preview edge adds', () => { + const changes: EdgeChange[] = [ + { + type: 'add', + item: { id: 'conn:1', source: 's', target: 't', type: SEQ_CONNECTOR_EDGE_TYPE }, + }, + { type: 'add', item: { id: PREVIEW_EDGE_ID, source: 's', target: PREVIEW_NODE_ID } }, + ]; + expect(forwardSequentialEdgeChanges(changes, canonicalEdgeIds, canonicalEdges)).toEqual([]); + }); + + it('forwards healed default edges and removes the shadowed canonical edge on insert', () => { + const changes: EdgeChange[] = [ + { type: 'add', item: { id: 'edge_s--new-', source: 's', target: 'new', type: 'default' } }, + { type: 'add', item: { id: 'edge_new--t-', source: 'new', target: 't', type: 'default' } }, + ]; + const out = forwardSequentialEdgeChanges(changes, canonicalEdgeIds, canonicalEdges); + + // Both healed edges forwarded. + expect(out.filter((change) => change.type === 'add')).toHaveLength(2); + // The canonical s->t edge is now shadowed and removed. + expect(out).toContainEqual({ type: 'remove', id: 'e-st' }); + // The unrelated canonical edge is untouched. + expect(out).not.toContainEqual({ type: 'remove', id: 'e-other' }); + }); + + it('uses the exact split marker and preserves parallel canonical edges', () => { + const parallelEdges: Edge[] = [ + { id: 'e-split', source: 's', sourceHandle: 'out', target: 't', targetHandle: 'in' }, + { id: 'e-parallel', source: 's', sourceHandle: 'out', target: 't', targetHandle: 'in' }, + ]; + const ids = new Set(parallelEdges.map((edge) => edge.id)); + const changes: EdgeChange[] = [ + { + type: 'add', + item: { + id: 'e-s-new', + source: 's', + sourceHandle: 'out', + target: 'new', + data: { __sequentialSplitEdgeId: 'e-split', keep: true }, + }, + }, + { + type: 'add', + item: { + id: 'e-new-t', + source: 'new', + target: 't', + targetHandle: 'in', + data: { __sequentialSplitEdgeId: 'e-split' }, + }, + }, + ]; + + const out = forwardSequentialEdgeChanges(changes, ids, parallelEdges); + expect(out).toContainEqual({ type: 'remove', id: 'e-split' }); + expect(out).not.toContainEqual({ type: 'remove', id: 'e-parallel' }); + const added = out.filter((change) => change.type === 'add'); + expect(added[0]?.item.data).toEqual({ keep: true }); + expect(added[1]?.item.data).toEqual({}); + }); + + it('forwards removes/selects of real canonical edges, drops others', () => { + const changes: EdgeChange[] = [ + { type: 'remove', id: 'e-st' }, + { type: 'remove', id: 'conn:1' }, + { type: 'select', id: 'e-other', selected: true }, + { type: 'select', id: 'conn:2', selected: true }, + ]; + const out = forwardSequentialEdgeChanges(changes, canonicalEdgeIds, canonicalEdges); + expect(out).toEqual([ + { type: 'remove', id: 'e-st' }, + { type: 'select', id: 'e-other', selected: true }, + ]); + }); +}); + +describe('graphChangeSetToNodeChanges (move operations)', () => { + it('produces a plain remove for an id that is only in removeNodeIds', () => { + const changeSet: GraphChangeSet = { + addNodes: [], + addEdges: [], + removeNodeIds: ['a'], + removeEdgeIds: [], + }; + expect(graphChangeSetToNodeChanges(changeSet)).toEqual([{ type: 'remove', id: 'a' }]); + }); + + it('produces a plain add for a genuinely new node (not also in removeNodeIds)', () => { + const node = canonical('new'); + const changeSet: GraphChangeSet = { + addNodes: [node], + addEdges: [], + removeNodeIds: [], + removeEdgeIds: [], + }; + expect(graphChangeSetToNodeChanges(changeSet)).toEqual([{ type: 'add', item: node }]); + }); + + it('rewrites a paired remove+add (same id, e.g. a cross-container parentId rewrite) into a single replace, preserving parentId', () => { + const movedNode: Node = { ...canonical('a'), parentId: 'new-container' }; + const changeSet: GraphChangeSet = { + addNodes: [movedNode], + addEdges: [], + removeNodeIds: ['a'], + removeEdgeIds: [], + }; + const changes = graphChangeSetToNodeChanges(changeSet); + expect(changes).toEqual([{ type: 'replace', id: 'a', item: movedNode }]); + // The parentId rewrite -- the whole point of the move -- survives. + expect((changes[0] as { item: Node }).item.parentId).toBe('new-container'); + }); + + it('produces no node changes for a same-container move (only edges change)', () => { + const changeSet: GraphChangeSet = { + addNodes: [], + addEdges: [{ id: 'e1', source: 'a', target: 'b' }], + removeNodeIds: [], + removeEdgeIds: ['e0'], + }; + expect(graphChangeSetToNodeChanges(changeSet)).toEqual([]); + }); +}); + +describe('graphChangeSetToEdgeChanges (move operations)', () => { + it('translates removeEdgeIds and addEdges into remove/add EdgeChanges', () => { + const edge: Edge = { id: 'e1', source: 'a', target: 'b' }; + const changeSet: GraphChangeSet = { + addNodes: [], + addEdges: [edge], + removeNodeIds: [], + removeEdgeIds: ['e0'], + }; + expect(graphChangeSetToEdgeChanges(changeSet)).toEqual([ + { type: 'remove', id: 'e0' }, + { type: 'add', item: edge }, + ]); + }); + + it('returns an empty array for a no-op GraphChangeSet', () => { + const changeSet: GraphChangeSet = { + addNodes: [], + addEdges: [], + removeNodeIds: [], + removeEdgeIds: [], + }; + expect(graphChangeSetToEdgeChanges(changeSet)).toEqual([]); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialChangeFilters.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialChangeFilters.ts new file mode 100644 index 000000000..a1837e242 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialChangeFilters.ts @@ -0,0 +1,246 @@ +import type { Edge, EdgeChange, Node, NodeChange } from '@uipath/apollo-react/canvas/xyflow/react'; +import { PREVIEW_NODE_ID } from '../../constants'; +import { isPreviewEdge } from '../../utils/createPreviewNode'; +import type { GraphChangeSet } from '../../utils/sequential/sequential.types'; +import { SEQ_INSERTED_FLAG, SEQ_SPLIT_EDGE_ID_KEY } from './edges/sequentialInsert'; +import { SEQ_CONNECTOR_EDGE_TYPE } from './sequentialGraph.constants'; + +/** + * Change filtering is the robust guarantee that the sequential view never + * corrupts canonical state (seam 2). In the sequential view the nodes/edges + * handed to ReactFlow are DERIVED clones + connector edges, not the canonical + * graph. ReactFlow (in controlled mode) reports every store mutation through + * onNodesChange / onEdgesChange as diff changes — including position and + * dimension changes the derivation owns, and the Add Node pipeline's collision + * pass nudging clones. Forwarding those verbatim would overwrite the consumer's + * canonical positions/geometry with view-only values. + * + * These pure functions translate the derived-view change stream into the subset + * that is meaningful for canonical state: + * - position + dimension changes are DROPPED (the derivation owns geometry); + * - changes referencing synthetic rows (start bar, placeholder, preview) are + * DROPPED (they never exist in canonical state); + * - a `replace` on a real node (e.g. an inline rename via updateNodeData) is + * rewritten to merge only `data` + `selected` onto the CANONICAL node, so the + * clone's view geometry (type / position / width / draggable) can't leak; + * - `select` and `remove` on real nodes/edges pass through; + * - an insert's added node (stamped `seqInserted`) and its healed real edges + * pass through, and the canonical edge the insert split is removed. + */ + +function nodeChangeId(change: NodeChange): string | undefined { + return change.type === 'add' ? change.item.id : change.id; +} + +function isInsertedNode(node: N): boolean { + return (node.data as Record | undefined)?.[SEQ_INSERTED_FLAG] === true; +} + +/** Filters/translates node changes from the derived view for the consumer's canonical state. */ +export function forwardSequentialNodeChanges( + changes: NodeChange[], + syntheticIds: ReadonlySet, + canonicalById: ReadonlyMap +): NodeChange[] { + const out: NodeChange[] = []; + + for (const change of changes) { + const id = nodeChangeId(change); + if (id !== undefined && (id === PREVIEW_NODE_ID || syntheticIds.has(id))) { + continue; + } + + switch (change.type) { + case 'add': + // Only a genuinely inserted node (seqInserted) reaches canonical here; + // any other add is a derivation/preview artifact. + if (isInsertedNode(change.item)) out.push(change); + break; + case 'remove': + case 'select': + out.push(change); + break; + case 'replace': { + const canonical = canonicalById.get(change.id); + if (!canonical) break; + out.push({ + type: 'replace', + id: change.id, + item: { ...canonical, data: change.item.data, selected: change.item.selected }, + }); + break; + } + // Derivation owns positions and bar dimensions; never forward them. + default: + break; + } + } + + return out; +} + +function isForwardableEdgeAdd(edge: E): boolean { + return !isPreviewEdge(edge) && edge.type !== SEQ_CONNECTOR_EDGE_TYPE; +} + +function splitEdgeId(edge: Edge): string | undefined { + const value = (edge.data as Record | undefined)?.[SEQ_SPLIT_EDGE_ID_KEY]; + return typeof value === 'string' ? value : undefined; +} + +function withoutSplitMarker(edge: E): E { + if (!splitEdgeId(edge)) return edge; + const { [SEQ_SPLIT_EDGE_ID_KEY]: _marker, ...data } = edge.data as Record; + return { ...edge, data }; +} + +/** + * Canonical edges shadowed by an insert: when a new node is spliced between S and + * T, the pipeline adds S->new and new->T, so the pivot node is the id that is + * both a target and a source among the added edges. Any pre-existing canonical + * edge S->T is now redundant and must be removed. + */ +function findShadowedCanonicalEdgeIds( + addedEdges: E[], + canonicalEdges: readonly E[] +): string[] { + const targetsOf = new Map>(); + const sourcesOf = new Map>(); + for (const edge of addedEdges) { + const incoming = targetsOf.get(edge.target) ?? []; + incoming.push({ nodeId: edge.source, handleId: edge.sourceHandle }); + targetsOf.set(edge.target, incoming); + const outgoing = sourcesOf.get(edge.source) ?? []; + outgoing.push({ nodeId: edge.target, handleId: edge.targetHandle }); + sourcesOf.set(edge.source, outgoing); + } + + const shadowed = new Set(); + for (const [pivot, incomingSources] of targetsOf) { + const outgoingTargets = sourcesOf.get(pivot); + if (!outgoingTargets) continue; + for (const source of incomingSources) { + for (const target of outgoingTargets) { + for (const edge of canonicalEdges) { + if ( + edge.source === source.nodeId && + edge.target === target.nodeId && + (edge.sourceHandle ?? undefined) === (source.handleId ?? undefined) && + (edge.targetHandle ?? undefined) === (target.handleId ?? undefined) + ) { + shadowed.add(edge.id); + } + } + } + } + } + return [...shadowed]; +} + +/** Filters/translates edge changes from the derived view for the consumer's canonical state. */ +export function forwardSequentialEdgeChanges( + changes: EdgeChange[], + canonicalEdgeIds: ReadonlySet, + canonicalEdges: readonly E[] +): EdgeChange[] { + const out: EdgeChange[] = []; + const addedEdges: E[] = []; + const exactSplitEdgeIds = new Set(); + + for (const change of changes) { + switch (change.type) { + case 'add': + if (isForwardableEdgeAdd(change.item)) { + const splitId = splitEdgeId(change.item); + if (splitId) exactSplitEdgeIds.add(splitId); + const item = withoutSplitMarker(change.item); + out.push({ ...change, item }); + addedEdges.push(item); + } + break; + case 'remove': + case 'select': + if (canonicalEdgeIds.has(change.id)) out.push(change); + break; + // Connector `data` replaces are view-only; canonical edges are not replaced + // through the view. + default: + break; + } + } + + if (addedEdges.length > 0) { + const removedIds = new Set( + out.filter((change) => change.type === 'remove').map((change) => change.id) + ); + // A pending insertion carries the exact canonical edge id. Do not also run + // topology inference in that case: two parallel edges can legitimately + // have identical endpoints and handles, and inference would remove both. + const shadowedIds = + exactSplitEdgeIds.size > 0 + ? exactSplitEdgeIds + : new Set(findShadowedCanonicalEdgeIds(addedEdges, canonicalEdges)); + for (const id of shadowedIds) { + if (!removedIds.has(id)) out.push({ type: 'remove', id }); + } + } + + return out; +} + +/** + * Converts a {@link GraphChangeSet} (the tree move operations -- see + * `sequentialMoveActions.ts` / `SequentialMoveActionsContext.tsx`) into + * `NodeChange[]` for the CANONICAL `onNodesChange` callback (D10: the public + * API stays the standard change stream). + * + * Unlike `forwardSequentialNodeChanges` above, these two functions do NOT + * translate the derived VIEW's own xyflow-emitted changes (that seam exists + * to strip view-only geometry off a DERIVED clone). A move's `GraphChangeSet` + * is built directly from `moveSubtree` over the CANONICAL `{nodes, edges}` + * (see `mutations.ts`), so every node/edge here already has the real shape -- + * routing it through the clone-stripping translator would be wrong (that + * translator's own `replace` case deliberately drops `parentId`, which is + * exactly the field a cross-container move needs to carry). `moveSubtree` + * expresses "this node's parentId changed" as a paired remove+add (same id in + * both `removeNodeIds` and `addNodes`); that pairing is rewritten here as a + * `replace` so xyflow updates the existing node in place instead of a + * remove/re-add flicker. A genuinely new id in `addNodes` (never happens for + * a move today, since moves never create nodes) still round-trips correctly + * as a plain `add`. + */ +export function graphChangeSetToNodeChanges( + changeSet: GraphChangeSet +): NodeChange[] { + const removedIds = new Set(changeSet.removeNodeIds); + const changes: NodeChange[] = []; + + for (const node of changeSet.addNodes) { + if (removedIds.has(node.id)) { + changes.push({ type: 'replace', id: node.id, item: node as N }); + } else { + changes.push({ type: 'add', item: node as N }); + } + } + + const replacedIds = new Set(changeSet.addNodes.map((node) => node.id)); + for (const id of changeSet.removeNodeIds) { + if (!replacedIds.has(id)) changes.push({ type: 'remove', id }); + } + + return changes; +} + +/** Edge half of {@link graphChangeSetToNodeChanges}: a plain add/remove translation. */ +export function graphChangeSetToEdgeChanges( + changeSet: GraphChangeSet +): EdgeChange[] { + const changes: EdgeChange[] = changeSet.removeEdgeIds.map((id) => ({ + type: 'remove' as const, + id, + })); + for (const edge of changeSet.addEdges) { + changes.push({ type: 'add', item: edge as E }); + } + return changes; +} diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialGraph.constants.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialGraph.constants.ts new file mode 100644 index 000000000..e678a698f --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialGraph.constants.ts @@ -0,0 +1,34 @@ +/** + * Shared, dependency-free constants for the sequential graph derivation + * (useSequentialGraph), the connector edges, and the change filters. Kept in its + * own module so the pure change-filter helpers don't pull in the React hook. + */ + +/** Edge `type` the derivation stamps on every connector edge (the registered connector edge type). */ +export const SEQ_CONNECTOR_EDGE_TYPE = 'sequentialConnector'; + +/** + * Stable node ids for the two synthetic rows the derivation injects view-only. + * They are filtered out of onNodesChange / onEdgesChange before forwarding, and + * never appear in canonical state. The double-underscore fencing keeps them from + * colliding with any real node id. + */ +export const SEQ_START_ROW_ID = '__sequential-start__'; +export const SEQ_PLACEHOLDER_ROW_ID = '__sequential-placeholder__'; + +/** Edge ids for the synthetic connectors joining the start/placeholder bars. */ +export const SEQ_START_EDGE_ID = '__sequential-start-edge__'; +export const SEQ_PLACEHOLDER_EDGE_ID = '__sequential-placeholder-edge__'; + +/** + * Node-count ceiling under which the sequential view renders EVERY row into the + * DOM (onlyRenderVisibleElements={false}) so reading order equals row order for + * screen readers (D8). Above it, xyflow's viewport virtualization is re-enabled + * (matching the flow canvas default): rendering many hundred fixed bars at once + * makes xyflow fire updateNodeInternals for all of them on mount, and that burst + * drives xyflow's own ResizeObserver / store-rerender cycle past React's nested + * update limit. Real sequential flows sit far below this; a run this large is a + * navigable list only in the loosest sense, so trading full-DOM a11y for + * virtualization stability at that scale is the right call. Documented tradeoff. + */ +export const SEQ_FULL_RENDER_MAX_NODES = 150; diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialMoveActions.test.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialMoveActions.test.ts new file mode 100644 index 000000000..87802670f --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialMoveActions.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, it } from 'vitest'; +import { + CONTAINER_CHAIN_NODE_IDS, + CROSS_CONTAINER_BRANCH_NODE_IDS, + makeContainerChainFixture, + makeCrossContainerBranchFixture, + makeDiamondFixture, + makeWireframeFixture, + WIREFRAME_NODE_IDS, +} from '../../utils/sequential/fixtures'; +import { projectSequence } from '../../utils/sequential/projectSequence'; +import { findMoveUpSlot } from '../../utils/sequential/slotNavigation'; +import { + closesLoopToOwner, + computeSequentialMoveOptions, + getSequentialMoveSlot, + isBareBranchOwner, + resolveSlotForCommit, + resolveTailInsertionSlot, +} from './sequentialMoveActions'; + +// Every fixture here uses `uipath.control-flow.foreach` for containers and +// `uipath.control-flow.decision` for branch owners (If/Switch); this stand-in +// registry check matches that convention rather than a real manifest lookup, +// since this module only needs a `nodeId => boolean` predicate. +const isForEachContainer = (nodeId: string, nodes: { id: string; type?: string }[]) => + nodes.find((n) => n.id === nodeId)?.type === 'uipath.control-flow.foreach'; + +describe('isBareBranchOwner', () => { + it('is true for a Decision (branch owner, not a container)', () => { + const { nodes, edges } = makeWireframeFixture(); + const projection = projectSequence(nodes, edges); + expect( + isBareBranchOwner(projection, WIREFRAME_NODE_IDS.ifNode, (id) => + isForEachContainer(id, nodes) + ) + ).toBe(true); + }); + + it('is false for a For Each (container)', () => { + const { nodes, edges } = makeWireframeFixture(); + const projection = projectSequence(nodes, edges); + expect( + isBareBranchOwner(projection, WIREFRAME_NODE_IDS.forEach, (id) => + isForEachContainer(id, nodes) + ) + ).toBe(false); + }); + + it('is false for a plain leaf step', () => { + const { nodes, edges } = makeWireframeFixture(); + const projection = projectSequence(nodes, edges); + expect( + isBareBranchOwner(projection, WIREFRAME_NODE_IDS.javascript, (id) => + isForEachContainer(id, nodes) + ) + ).toBe(false); + }); +}); + +describe('computeSequentialMoveOptions', () => { + it('disables ALL FOUR directions for a bare branch owner (If/Switch) that is the sole/first child of its container (wireframe)', () => { + const { nodes, edges } = makeWireframeFixture(); + const projection = projectSequence(nodes, edges); + const isContainerNode = (id: string) => isForEachContainer(id, nodes); + + const options = computeSequentialMoveOptions( + projection, + WIREFRAME_NODE_IDS.ifNode, + isContainerNode + ); + expect(options).toEqual({ + up: undefined, + down: undefined, + indent: undefined, + outdent: undefined, + }); + }); + + it('disables ALL FOUR directions for a top-level bare branch owner, even though findMoveUpSlot ALONE would return a defined (unsound) slot', () => { + // makeDiamondFixture: A -> If {true: B, false: C} -> D. A->If is a genuine + // 'step' connector (A has a single outgoing edge), so `findMoveUpSlot` + // does NOT naturally return undefined for `If` here the way it does for + // the wireframe's `ifNode` (which is the FIRST/only child of its + // container, reached only via a 'branch-entry' connector) -- this is + // exactly the premise that makes this module's extra gate load-bearing + // for Move Up (see isBareBranchOwner's doc comment). + const { nodes, edges } = makeDiamondFixture(); + const projection = projectSequence(nodes, edges); + const isContainerNode = (id: string) => isForEachContainer(id, nodes); + + expect(findMoveUpSlot(projection, 'if')).toBeDefined(); + + const options = computeSequentialMoveOptions(projection, 'if', isContainerNode); + expect(options).toEqual({ + up: undefined, + down: undefined, + indent: undefined, + outdent: undefined, + }); + }); + + it('does not gate a container (For Each): outdent works normally for its body children', () => { + const { nodes, edges } = makeContainerChainFixture(); + const projection = projectSequence(nodes, edges); + const ids = CONTAINER_CHAIN_NODE_IDS; + const isContainerNode = (id: string) => isForEachContainer(id, nodes); + + const options = computeSequentialMoveOptions(projection, ids.y, isContainerNode); + expect(options.outdent).toBeDefined(); + }); + + it('allows move up/down for a plain leaf step (javascript in the wireframe)', () => { + const { nodes, edges } = makeWireframeFixture(); + const projection = projectSequence(nodes, edges); + const isContainerNode = (id: string) => isForEachContainer(id, nodes); + + const options = computeSequentialMoveOptions( + projection, + WIREFRAME_NODE_IDS.javascript, + isContainerNode + ); + expect(options.up).toBeDefined(); + expect(options.down).toBeDefined(); + }); + + it('does not synthesize an outdent seam for a branch-lane child', () => { + const { nodes, edges } = makeWireframeFixture(); + const projection = projectSequence(nodes, edges); + const options = computeSequentialMoveOptions(projection, WIREFRAME_NODE_IDS.thenJs, (id) => + isForEachContainer(id, nodes) + ); + expect(options.outdent).toBeUndefined(); + }); + + it('disables ALL FOUR directions for a bare branch owner nested across a container boundary (cross-container fixture)', () => { + const { nodes, edges } = makeCrossContainerBranchFixture(); + const projection = projectSequence(nodes, edges); + const ids = CROSS_CONTAINER_BRANCH_NODE_IDS; + const isContainerNode = (id: string) => isForEachContainer(id, nodes); + + const options = computeSequentialMoveOptions(projection, ids.ifNode, isContainerNode); + expect(options).toEqual({ + up: undefined, + down: undefined, + indent: undefined, + outdent: undefined, + }); + }); +}); + +describe('closesLoopToOwner', () => { + it('identifies the raw continue edge from a body tail back to its owner', () => { + const { nodes, edges } = makeWireframeFixture(); + const projection = projectSequence(nodes, edges); + expect(closesLoopToOwner(projection, WIREFRAME_NODE_IDS.thenJs, edges)).toBe(false); + // The projected owner of Then is the If branch, not the For Each container; + // branch protection above handles this shape. Verify the helper on a small + // direct loop body where the row owner and close-edge target are identical. + const directProjection = { + ...projection, + rows: projection.rows.map((row) => + row.nodeId === WIREFRAME_NODE_IDS.thenJs + ? { ...row, parentRowId: WIREFRAME_NODE_IDS.forEach } + : row + ), + }; + expect(closesLoopToOwner(directProjection, WIREFRAME_NODE_IDS.thenJs, edges)).toBe(true); + }); +}); + +describe('getSequentialMoveSlot', () => { + it('reads the matching direction from a SequentialMoveOptions bag', () => { + const options = { + up: { id: 'up' }, + down: undefined, + indent: { id: 'indent' }, + outdent: undefined, + } as const; + expect(getSequentialMoveSlot(options, 'up')).toEqual({ id: 'up' }); + expect(getSequentialMoveSlot(options, 'down')).toBeUndefined(); + expect(getSequentialMoveSlot(options, 'indent')).toEqual({ id: 'indent' }); + expect(getSequentialMoveSlot(options, 'outdent')).toBeUndefined(); + }); +}); + +describe('resolveSlotForCommit', () => { + const nodesById = new Map([ + ['n1', { id: 'n1', type: 'uipath.script', position: { x: 0, y: 0 } }], + ]); + + it('replaces a synthesized DEFAULT_SOURCE_HANDLE_ID with the registry-resolved default source handle', () => { + const slot = { id: 'slot', source: { nodeId: 'n1', handleId: 'output' } }; + const resolved = resolveSlotForCommit(slot, nodesById, () => 'real-output-handle'); + expect(resolved.source?.handleId).toBe('real-output-handle'); + }); + + it('leaves the slot unchanged when the source handle is not the synthesized default', () => { + const slot = { id: 'slot', source: { nodeId: 'n1', handleId: 'custom-handle' } }; + const resolved = resolveSlotForCommit(slot, nodesById, () => 'real-output-handle'); + expect(resolved).toBe(slot); + }); + + it('leaves the slot unchanged when the registry has nothing better to offer', () => { + const slot = { id: 'slot', source: { nodeId: 'n1', handleId: 'output' } }; + const resolved = resolveSlotForCommit(slot, nodesById, () => undefined); + expect(resolved).toBe(slot); + }); + + it('leaves a target-only slot (no source) unchanged', () => { + const slot = { id: 'slot', target: { nodeId: 'n1' } }; + const resolved = resolveSlotForCommit(slot, nodesById, () => 'real-output-handle'); + expect(resolved).toBe(slot); + }); +}); + +describe('resolveTailInsertionSlot', () => { + it('uses the terminal manifest default source handle and skips trailing orphans', () => { + const { nodes, edges } = makeWireframeFixture(); + const projection = projectSequence(nodes, edges); + projection.rows.push({ + nodeId: 'orphan', + depth: 0, + collapsible: false, + collapsed: false, + visible: true, + orphan: true, + }); + const slot = resolveTailInsertionSlot( + projection, + [...nodes, { id: 'orphan', type: 'orphan', position: { x: 0, y: 0 }, data: {} }], + (type) => (type === 'uipath.send-message' ? 'success-port' : undefined) + ); + expect(slot).toEqual({ + id: `slot:tail:${WIREFRAME_NODE_IDS.sendMessage}`, + source: { nodeId: WIREFRAME_NODE_IDS.sendMessage, handleId: 'success-port' }, + }); + }); + + it('uses the lone start node when the projected sequence is empty', () => { + const start = { + id: 'start', + type: 'uipath.trigger.manual', + position: { x: 0, y: 0 }, + data: {}, + }; + const projection = projectSequence([start], [], { isStartNode: () => true }); + + const slot = resolveTailInsertionSlot( + projection, + [start], + () => 'trigger-output', + (node) => node.id === start.id + ); + + expect(slot).toEqual({ + id: 'slot:tail:start', + source: { nodeId: 'start', handleId: 'trigger-output' }, + }); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialMoveActions.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialMoveActions.ts new file mode 100644 index 000000000..03cb4211d --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialMoveActions.ts @@ -0,0 +1,178 @@ +import type { Edge, Node } from '@uipath/apollo-react/canvas/xyflow/react'; +import { DEFAULT_SOURCE_HANDLE_ID } from '../../constants'; +import type { InsertionSlot, SequenceProjection } from '../../utils/sequential/sequential.types'; +import { + findIndentSlot, + findMoveDownSlot, + findMoveUpSlot, + findOutdentSlot, +} from '../../utils/sequential/slotNavigation'; + +/** + * Pure core for the explorer-like tree move operations (Move up/down, + * indent/outdent), kept dependency-free from React so the disable logic and + * handle-resolution are unit-testable without mounting BaseNode/BaseCanvas. + * The React binding (SequentialMoveActionsContext) wires this to the + * projection/canonical graph/registry and to onNodesChange/onEdgesChange. + */ + +export type SequentialMoveDirection = 'up' | 'down' | 'indent' | 'outdent'; + +/** A candidate move: `slot` is the target `moveSubtree` would use; `undefined` means disabled. */ +export interface SequentialMoveOptions { + up: InsertionSlot | undefined; + down: InsertionSlot | undefined; + indent: InsertionSlot | undefined; + outdent: InsertionSlot | undefined; +} + +/** + * True when `nodeId`'s row is a "bare branch owner": collapsible (per + * `SequenceRow.collapsible`, which is true for BOTH containers and branch + * owners like If/Switch) but NOT a structural container. + * + * ENGINE CONTRACT EXTENSION: the binding + * contract text calls for this gate on Outdent only. This module applies it + * to ALL FOUR operations instead, because the underlying limitation is not + * outdent-specific. `moveSubtree`'s own doc comment (utils/sequential/mutations.ts) + * and its `collectOwnSeam` helper establish that a bare branch owner has NO + * genuine "own outgoing" connector -- `projectSequence` only ever emits + * `branch-entry` connectors sourced at such a node (into its Then/Else lanes), + * never a real forward `step` past it, because the flow only "continues" + * again via the merge node reached through the branch tails. + * + * Consequence, verified against the engine's own + * `makeCrossContainerBranchFixture` test (mutations.test.ts, "relocates a bare + * branch owner WITH its lane content across a container boundary"): the ONLY + * slot shape that test exercises for moving such a node is a SOURCE-ONLY + * (append) slot with no `target`/`graphEdgeId` -- explicitly "so the splice + * only ADDS an incoming edge to `If` and never a competing outgoing one... + * splicing a new OUTGOING edge onto a bare branch owner is unsound (it always + * reads as a third lane, not a 'next step')". `findMoveUpSlot` and + * `findIndentSlot` do NOT guarantee a source-only slot (they can return a + * splice or a prepend, both of which hand the owner a new outgoing edge), so + * "Move up" and "Move into previous step" are just as unsound as Outdent for + * these nodes -- not merely "no genuine forward step" (which already, + * separately, makes `findMoveDownSlot` naturally return `undefined` with no + * extra gate needed). Disabling all four is the only interpretation that + * can't corrupt the graph. + */ +export function isBareBranchOwner( + projection: SequenceProjection, + nodeId: string, + isContainerNode: (nodeId: string) => boolean +): boolean { + const row = projection.rows.find((candidate) => candidate.nodeId === nodeId); + return !!row?.collapsible && !isContainerNode(nodeId); +} + +/** + * Computes the four move candidates for `nodeId`. A bare branch owner (see + * {@link isBareBranchOwner}) gets all four disabled regardless of what the + * individual `find*Slot` helpers return. + */ +export function computeSequentialMoveOptions( + projection: SequenceProjection, + nodeId: string, + isContainerNode: (nodeId: string) => boolean +): SequentialMoveOptions { + if (isBareBranchOwner(projection, nodeId, isContainerNode)) { + return { up: undefined, down: undefined, indent: undefined, outdent: undefined }; + } + const row = projection.rows.find((candidate) => candidate.nodeId === nodeId); + const ownerId = row?.parentRowId; + const ownerIsBareBranch = ownerId + ? isBareBranchOwner(projection, ownerId, isContainerNode) + : false; + return { + up: findMoveUpSlot(projection, nodeId), + down: findMoveDownSlot(projection, nodeId), + indent: findIndentSlot(projection, nodeId), + // A branch lane has no single semantic "after owner" seam. Synthesizing + // owner.output would create a new branch instead of moving the row out. + outdent: ownerIsBareBranch ? undefined : findOutdentSlot(projection, nodeId), + }; +} + +/** A loop-body tail cannot be outdented by splicing its close edge as a forward seam. */ +export function closesLoopToOwner( + projection: SequenceProjection, + nodeId: string, + edges: readonly Edge[] +): boolean { + const ownerId = projection.rows.find((row) => row.nodeId === nodeId)?.parentRowId; + return !!ownerId && edges.some((edge) => edge.source === nodeId && edge.target === ownerId); +} + +/** Reads the slot for a single direction (used by the keyboard handler). */ +export function getSequentialMoveSlot( + options: SequentialMoveOptions, + direction: SequentialMoveDirection +): InsertionSlot | undefined { + switch (direction) { + case 'up': + return options.up; + case 'down': + return options.down; + case 'indent': + return options.indent; + case 'outdent': + return options.outdent; + } +} + +/** + * Re-resolves a synthesized slot's SOURCE handle against the registry before + * committing (engine contract): `slotNavigation.ts`'s fallback slots stamp the + * generic `DEFAULT_SOURCE_HANDLE_ID` ('output') when there is no real edge to + * read a handle id from. When the registry knows the source node's actual + * default source handle (which may differ, e.g. a node type with no literal + * "output" handle id), that real id is used instead. A no-op when the slot's + * source handle is already something else (a real handle id copied from an + * existing edge), or when the registry has nothing better to offer. + */ +export function resolveSlotForCommit( + slot: InsertionSlot, + nodesById: ReadonlyMap, + getDefaultSourceHandleId: (nodeType: string) => string | undefined +): InsertionSlot { + if (!slot.source || slot.source.handleId !== DEFAULT_SOURCE_HANDLE_ID) return slot; + const sourceType = nodesById.get(slot.source.nodeId)?.type; + if (!sourceType) return slot; + const resolved = getDefaultSourceHandleId(sourceType); + if (!resolved || resolved === DEFAULT_SOURCE_HANDLE_ID) return slot; + return { ...slot, source: { ...slot.source, handleId: resolved } }; +} + +/** The graph shape `moveSubtree` needs for cross-container `parentId` rewrites. */ +export interface CanonicalGraph { + nodes: Node[]; + edges: Edge[]; +} + +/** Builds the terminal append slot without assuming a literal `output` handle. */ +export function resolveTailInsertionSlot( + projection: SequenceProjection | null, + nodes: readonly N[], + getDefaultSourceHandleId: (nodeType: string) => string | undefined, + isStartNode?: (node: N) => boolean +): InsertionSlot | undefined { + const topRows = projection?.rows.filter((row) => row.depth === 0 && row.visible && !row.orphan); + const last = topRows?.[topRows.length - 1]; + // Start nodes are folded into the synthetic "Workflow start" row. When the + // graph contains only one such node, use it as the terminal append source so + // the otherwise-empty "Add step" row still opens a valid insertion. + const startNodes = !last && isStartNode ? nodes.filter(isStartNode) : []; + const loneStart = startNodes.length === 1 ? startNodes[0] : undefined; + const sourceNode = last ? nodes.find((node) => node.id === last.nodeId) : loneStart; + if (!sourceNode) return undefined; + const nodeType = sourceNode.type; + return { + id: `slot:tail:${sourceNode.id}`, + source: { + nodeId: sourceNode.id, + handleId: + (nodeType ? getDefaultSourceHandleId(nodeType) : undefined) ?? DEFAULT_SOURCE_HANDLE_ID, + }, + }; +} diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/synthesizePositionsForFlow.test.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/synthesizePositionsForFlow.test.ts new file mode 100644 index 000000000..ed85225d1 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/synthesizePositionsForFlow.test.ts @@ -0,0 +1,100 @@ +import type { Edge, Node } from '@uipath/apollo-react/canvas/xyflow/react'; +import { describe, expect, it } from 'vitest'; +import { SEQ_INSERTED_FLAG } from './edges/sequentialInsert'; +import { synthesizePositionsForFlow } from './synthesizePositionsForFlow'; + +function boxesOverlap(a: Node, b: Node): boolean { + const aw = a.width ?? 96; + const ah = a.height ?? 96; + const bw = b.width ?? 96; + const bh = b.height ?? 96; + return ( + a.position.x < b.position.x + bw && + a.position.x + aw > b.position.x && + a.position.y < b.position.y + bh && + a.position.y + ah > b.position.y + ); +} + +function makeNode(id: string, x: number, y: number, extra: Partial = {}): Node { + return { + id, + type: 'uipath.script', + position: { x, y }, + data: { display: { label: id } }, + ...extra, + }; +} + +describe('synthesizePositionsForFlow', () => { + it('returns the same array reference when nothing was inserted', () => { + const nodes = [makeNode('a', 0, 0), makeNode('b', 0, 200)]; + const result = synthesizePositionsForFlow(nodes, []); + expect(result).toBe(nodes); + }); + + it('places an inserted node without overlap and clears the sequential markers', () => { + const a = makeNode('a', 0, 0, { width: 96, height: 96 }); + const inserted: Node = { + id: 'n', + type: 'uipath.slack', + position: { x: 0, y: 0 }, + draggable: false, + data: { display: { label: 'Slack' }, [SEQ_INSERTED_FLAG]: true }, + }; + const edges: Edge[] = [{ id: 'e', source: 'a', target: 'n' }]; + + const result = synthesizePositionsForFlow([a, inserted], edges); + const placed = result.find((node) => node.id === 'n')!; + + // Placed to the right of its source, no overlap with A. + expect(placed.position.x).toBeGreaterThan(a.position.x); + expect(boxesOverlap(placed, a)).toBe(false); + // Markers cleared. + expect((placed.data as Record)[SEQ_INSERTED_FLAG]).toBeUndefined(); + expect(placed.draggable).toBeUndefined(); + // Original label data preserved. + expect((placed.data as { display: { label: string } }).display.label).toBe('Slack'); + }); + + it('never moves a pre-existing node (returns them by identity)', () => { + const a = makeNode('a', 0, 0); + const b = makeNode('b', 500, 500); + const inserted: Node = { + id: 'n', + type: 'uipath.script', + position: { x: 0, y: 0 }, + data: { [SEQ_INSERTED_FLAG]: true }, + }; + const edges: Edge[] = [{ id: 'e', source: 'a', target: 'n' }]; + + const result = synthesizePositionsForFlow([a, b, inserted], edges); + + expect(result.find((n) => n.id === 'a')).toBe(a); + expect(result.find((n) => n.id === 'b')).toBe(b); + }); + + it('separates multiple inserted nodes so none overlap', () => { + const a = makeNode('a', 0, 0, { width: 96, height: 96 }); + const makeInserted = (id: string): Node => ({ + id, + type: 'uipath.script', + position: { x: 0, y: 0 }, + data: { [SEQ_INSERTED_FLAG]: true }, + }); + // Both inserted nodes have the same source, so they anchor to the same spot + // and must be pushed apart. + const edges: Edge[] = [ + { id: 'e1', source: 'a', target: 'n1' }, + { id: 'e2', source: 'a', target: 'n2' }, + ]; + + const result = synthesizePositionsForFlow([a, makeInserted('n1'), makeInserted('n2')], edges); + const n1 = result.find((n) => n.id === 'n1')!; + const n2 = result.find((n) => n.id === 'n2')!; + + expect(boxesOverlap(n1, n2)).toBe(false); + expect(boxesOverlap(n1, a)).toBe(false); + expect(boxesOverlap(n2, a)).toBe(false); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/synthesizePositionsForFlow.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/synthesizePositionsForFlow.ts new file mode 100644 index 000000000..89a678e58 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/synthesizePositionsForFlow.ts @@ -0,0 +1,95 @@ +import type { Edge, Node } from '@uipath/apollo-react/canvas/xyflow/react'; +import { DEFAULT_NODE_SIZE, GRID_SPACING } from '../../constants'; +import { getNonOverlappingPositionForDirection, snapToGrid } from '../../utils/NodeUtils'; +import { SEQ_INSERTED_FLAG } from './edges/sequentialInsert'; + +/** + * Places nodes that were inserted while in the sequential view (D4). + * + * Sequential view never persists positions: layout owns them, and canonical + * `node.position` is left untouched so toggling back to flow is lossless. The + * one exception is a node the user added while in sequential view, which has no + * meaningful flow position. Those are stamped `data.seqInserted` (by + * `sequentialOnBeforeNodeAdded`); on toggle back to flow this function gives each + * one a real, non-overlapping position and clears the flag. + * + * Guarantees (asserted by the round-trip test): + * - NO pre-existing node is moved. Existing nodes are only read as obstacles; + * their objects are returned by identity. Only `seqInserted` nodes change. + * - No inserted node overlaps another node. Placement uses + * `getNonOverlappingPositionForDirection`, which shifts ONLY the new node + * (never the obstacles) until it clears them — the opposite of + * `resolveCollisions`, which would move both and violate the guarantee. + * + * Placement anchors each inserted node just to the right of its seam source (the + * node its incoming edge comes from), matching the left-to-right bias of the + * free-form Add Node pipeline, then resolves overlap by shifting downward. + * Obstacle checks stay within the node's own coordinate frame (same `parentId`), + * so a node inserted inside a container is placed relative to its siblings. + */ +export function synthesizePositionsForFlow(nodes: Node[], edges: Edge[]): Node[] { + const hasInserted = nodes.some((node) => isSeqInserted(node)); + if (!hasInserted) return nodes; + + const gap = GRID_SPACING * 5; + const result = [...nodes]; + const indexById = new Map(result.map((node, index) => [node.id, index])); + const incomingByTarget = new Map(); + for (const edge of edges) { + if (!incomingByTarget.has(edge.target)) incomingByTarget.set(edge.target, edge); + } + + for (let i = 0; i < result.length; i++) { + const node = result[i]!; + if (!isSeqInserted(node)) continue; + + const size = { + width: node.width ?? node.measured?.width ?? DEFAULT_NODE_SIZE, + height: node.height ?? node.measured?.height ?? DEFAULT_NODE_SIZE, + }; + + const sourceEdge = incomingByTarget.get(node.id); + const sourceIndex = sourceEdge ? indexById.get(sourceEdge.source) : undefined; + const source = sourceIndex !== undefined ? result[sourceIndex] : undefined; + const sourceWidth = source?.width ?? source?.measured?.width ?? DEFAULT_NODE_SIZE; + + const anchor = source + ? { x: source.position.x + sourceWidth + gap, y: source.position.y } + : { x: 0, y: 0 }; + + // Obstacles: every other node sharing this node's coordinate frame. Same + // frame => comparing raw `position` is valid; nodes in other frames are + // irrelevant to overlap here. + const obstacles = result.filter( + (other) => + other.id !== node.id && (other.parentId ?? undefined) === (node.parentId ?? undefined) + ); + + const placed = getNonOverlappingPositionForDirection( + obstacles, + { x: snapToGrid(anchor.x), y: snapToGrid(anchor.y) }, + size, + 'right', + GRID_SPACING * 2 + ); + + result[i] = clearInsertedMarker(node, placed); + } + + return result; +} + +function isSeqInserted(node: Node): boolean { + return (node.data as Record | undefined)?.[SEQ_INSERTED_FLAG] === true; +} + +/** + * Returns a node with a real flow position and the sequential-only markers + * removed: the `seqInserted` flag is dropped and `draggable` (forced false by the + * sequential insert helper) is cleared so the node behaves normally in flow view. + */ +function clearInsertedMarker(node: Node, position: { x: number; y: number }): Node { + const { [SEQ_INSERTED_FLAG]: _flag, ...restData } = node.data as Record; + const { draggable: _draggable, ...restNode } = node; + return { ...restNode, position, data: restData }; +} diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/useCanvasViewMode.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/useCanvasViewMode.ts new file mode 100644 index 000000000..cc9097818 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/useCanvasViewMode.ts @@ -0,0 +1,26 @@ +import { useCallback } from 'react'; +import { useStorageState } from '../../hooks/useStorageState'; +import type { CanvasView } from '../../utils/sequential/sequential.types'; + +/** + * Persists the flow/sequential view choice per canvas (D11), wrapping the + * existing {@link useStorageState} localStorage helper. The host passes a stable + * `storageKey` (typically per-canvas); the choice survives reloads. Open product + * question Q4 notes a host-owned preference service can replace this later + * without touching the view components. + */ +export function useCanvasViewMode( + storageKey: string, + initial: CanvasView = 'flow' +): [CanvasView, (view: CanvasView) => void] { + const [view, setStoredView] = useStorageState(storageKey, initial); + + const setView = useCallback( + (next: CanvasView) => { + setStoredView(next); + }, + [setStoredView] + ); + + return [view, setView]; +} diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/useSequentialGraph.test.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/useSequentialGraph.test.ts new file mode 100644 index 000000000..7abf96b96 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/useSequentialGraph.test.ts @@ -0,0 +1,483 @@ +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.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 the fixed bar height so the store height never churns', () => { + const { nodes, edges } = makeWireframeFixture(); + const graph = deriveSequentialGraph({ nodes, edges, view: 'sequential' }); + + for (const node of graph.nodes) { + expect(node.height).toBe(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 populated branch tails with the same clickable placeholder row as empty lanes', () => { + 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, + }); + 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, + }) + ); + expect( + graph.edges.some( + (edge) => edge.source === WIREFRAME_NODE_IDS.thenJs && edge.target === placeholder?.id + ) + ).toBe(true); + }); + + it('flattens container children and indents them by depth', () => { + const { nodes, edges } = makeWireframeFixture(); + const graph = deriveSequentialGraph({ nodes, edges, view: 'sequential' }); + + // The If is a body child of For Each -> depth 1 -> x = 1 * indent, parentId cleared. + const ifNode = graph.nodes.find((node) => node.id === WIREFRAME_NODE_IDS.ifNode)!; + expect(ifNode.parentId).toBeUndefined(); + expect(ifNode.position.x).toBe(SEQ_INDENT_PX); + }); + + it('applies custom node dimensions, indentation, and vertical gap to the real layout', () => { + const { nodes, edges } = makeWireframeFixture(); + const customWidth = 640; + const customHeight = 80; + const customIndent = 128; + const customGap = 80; + const graph = deriveSequentialGraph({ + nodes, + edges, + view: 'sequential', + layoutOptions: { + barWidth: customWidth, + barHeight: customHeight, + indent: customIndent, + rowGap: customGap, + }, + }); + + expect(graph.nodes.every((node) => node.width === customWidth)).toBe(true); + expect(graph.nodes.every((node) => node.height === customHeight)).toBe(true); + expect(graph.nodes.find((node) => node.id === WIREFRAME_NODE_IDS.ifNode)?.position.x).toBe( + customIndent + ); + const httpY = graph.nodes.find((node) => node.id === WIREFRAME_NODE_IDS.http)?.position.y ?? 0; + const javascriptY = + graph.nodes.find((node) => node.id === WIREFRAME_NODE_IDS.javascript)?.position.y ?? 0; + expect(javascriptY - httpY).toBe(customHeight + customGap); + }); + + it('preserves the canonical data reference and selection passthrough', () => { + const { nodes, edges } = makeWireframeFixture(); + const selected = nodes.map((node) => + node.id === WIREFRAME_NODE_IDS.http ? { ...node, selected: true } : node + ); + const canonicalHttp = selected.find((node) => node.id === WIREFRAME_NODE_IDS.http)!; + const graph = deriveSequentialGraph({ nodes: selected, edges, view: 'sequential' }); + + const http = graph.nodes.find((node) => node.id === WIREFRAME_NODE_IDS.http)!; + expect(http.data).toBe(canonicalHttp.data); + expect(http.selected).toBe(true); + }); + + it('builds connector edges with the bar handle ids plus synthetic joins', () => { + const { nodes, edges } = makeWireframeFixture(); + const graph = deriveSequentialGraph({ nodes, edges, view: 'sequential' }); + + for (const edge of graph.edges) { + expect(edge.type).toBe(SEQ_CONNECTOR_EDGE_TYPE); + expect(edge.sourceHandle).toBe(SEQUENTIAL_BAR_HANDLE_IDS.source); + // Branch/container-entry connectors enter the child's mid-left; every + // other kind drops into the top handle. + expect(edge.targetHandle).toBe( + edge.data?.kind === 'branch-entry' + ? SEQUENTIAL_BAR_HANDLE_IDS.branchTarget + : SEQUENTIAL_BAR_HANDLE_IDS.target + ); + } + // Every projection connector + 2 synthetic joins (start->first, last->placeholder). + expect(graph.edges).toHaveLength((graph.projection?.connectors.length ?? 0) + 2); + expect(graph.edges.some((edge) => edge.source === SEQ_START_ROW_ID)).toBe(true); + expect(graph.edges.some((edge) => edge.target === SEQ_PLACEHOLDER_ROW_ID)).toBe(true); + + const startEdge = graph.edges.find((edge) => edge.source === SEQ_START_ROW_ID); + expect(startEdge?.data?.hideArrowHead).toBe(false); + expect(startEdge?.data?.slot).toEqual({ + id: `slot:head:${WIREFRAME_NODE_IDS.http}`, + target: { nodeId: WIREFRAME_NODE_IDS.http }, + }); + }); + + it('renders a split preview with the same projected connectors as the committed row', () => { + const { nodes, edges } = makeWireframeFixture(); + const base = deriveSequentialGraph({ nodes, edges, view: 'sequential' }); + const slot = base.projection?.connectors.find( + (connector) => + connector.sourceRowId === WIREFRAME_NODE_IDS.http && + connector.targetRowId === WIREFRAME_NODE_IDS.javascript + )?.slot; + expect(slot).toBeDefined(); + + const graph = deriveSequentialGraph({ nodes, edges, view: 'sequential', insertSlot: slot }); + const previewEdges = graph.edges.filter( + (edge) => edge.source === PREVIEW_NODE_ID || edge.target === PREVIEW_NODE_ID + ); + + expect(previewEdges).toHaveLength(2); + expect(previewEdges.every((edge) => edge.data?.preview === true)).toBe(true); + expect(previewEdges.every((edge) => edge.data?.slot === undefined)).toBe(true); + expect(previewEdges.every((edge) => edge.style?.opacity === 0.8)).toBe(true); + }); + + it('opens a real gap before the first row without moving the start bar into the preview', () => { + const { nodes, edges } = makeWireframeFixture(); + const base = deriveSequentialGraph({ nodes, edges, view: 'sequential' }); + const startEdge = base.edges.find((edge) => edge.source === SEQ_START_ROW_ID); + const slot = startEdge?.data?.slot; + expect(slot).toBeDefined(); + + const graph = deriveSequentialGraph({ nodes, edges, view: 'sequential', insertSlot: slot }); + const start = graph.nodes.find((node) => node.id === SEQ_START_ROW_ID)!; + const previewY = graph.layout?.positions.get(PREVIEW_NODE_ID)?.y; + const firstY = graph.layout?.positions.get(WIREFRAME_NODE_IDS.http)?.y; + + expect(previewY).toBeDefined(); + expect(firstY).toBeDefined(); + expect(start.position.y).toBe((previewY ?? 0) - (SEQ_BAR_HEIGHT + SEQ_ROW_GAP)); + expect(firstY).toBe((previewY ?? 0) + SEQ_BAR_HEIGHT + SEQ_ROW_GAP); + expect(new Set([start.position.y, previewY, firstY]).size).toBe(3); + }); + + it('keeps a branch preview on the branch-entry elbow and left target handle', () => { + const { nodes, edges } = makeWireframeFixture(); + const base = deriveSequentialGraph({ nodes, edges, view: 'sequential' }); + const slot = base.projection?.connectors.find( + (connector) => + connector.sourceRowId === WIREFRAME_NODE_IDS.ifNode && + connector.targetRowId === WIREFRAME_NODE_IDS.thenJs + )?.slot; + expect(slot).toBeDefined(); + + const graph = deriveSequentialGraph({ nodes, edges, view: 'sequential', insertSlot: slot }); + const incoming = graph.edges.find((edge) => edge.target === PREVIEW_NODE_ID); + + expect(incoming?.data?.kind).toBe('branch-entry'); + expect(incoming?.targetHandle).toBe(SEQUENTIAL_BAR_HANDLE_IDS.branchTarget); + expect(incoming?.data?.waypoints).toHaveLength(2); + }); + + it('draws the tail preview through to the placeholder without materializing that visual join', () => { + const { nodes, edges } = makeWireframeFixture(); + const graph = deriveSequentialGraph({ + nodes, + edges, + view: 'sequential', + insertSlot: { + id: 'slot:tail', + source: { nodeId: WIREFRAME_NODE_IDS.sendMessage, handleId: 'output' }, + }, + }); + const placeholderJoin = graph.edges.find( + (edge) => edge.source === PREVIEW_NODE_ID && edge.target === SEQ_PLACEHOLDER_ROW_ID + ); + + expect(placeholderJoin).toMatchObject({ + sourceHandle: 'output', + data: { preview: true, ignorePreviewConnection: true }, + }); + }); + + it('places the placeholder before de-emphasized orphan rows', () => { + const { nodes, edges } = makeOrphanFixture(); + const graph = deriveSequentialGraph({ nodes, edges, view: 'sequential' }); + expect(graph.nodes.map((node) => node.id)).toEqual([ + SEQ_START_ROW_ID, + 'a', + 'b', + SEQ_PLACEHOLDER_ROW_ID, + 'z', + ]); + expect(graph.nodes.at(-1)?.className).toContain('opacity-60'); + expect(graph.nodes.find((node) => node.id === SEQ_PLACEHOLDER_ROW_ID)?.position.y).toBeLessThan( + graph.nodes.find((node) => node.id === 'z')?.position.y ?? 0 + ); + }); + + it('places the placeholder below a trailing container body, not inside it', () => { + // root -> c1 (container: c2 -> leaf). c1 is the last top-level row, but its + // body (c2, leaf and its branch-tail placeholder) extends below it; the + // terminal placeholder must sit under the whole stack. + const { nodes, edges } = makeDeepNestingFixture(); + const graph = deriveSequentialGraph({ nodes, edges, view: 'sequential' }); + const placeholderY = + graph.nodes.find((node) => node.id === SEQ_PLACEHOLDER_ROW_ID)?.position.y ?? 0; + const leafY = graph.nodes.find((node) => node.id === 'leaf')?.position.y ?? 0; + const c1Y = graph.nodes.find((node) => node.id === 'c1')?.position.y ?? 0; + expect(leafY).toBeGreaterThan(c1Y); + expect(placeholderY).toBeGreaterThan(leafY); + }); + + it('passes the canonical graph through unchanged in flow view', () => { + const { nodes, edges } = makeWireframeFixture(); + const graph = deriveSequentialGraph({ nodes, edges, view: 'flow' }); + // deriveSequentialGraph always projects; the hook is what short-circuits flow + // view. This asserts the projection still runs without throwing. + expect(graph.projection).not.toBeNull(); + }); +}); + +describe('useSequentialGraph memoization (D12)', () => { + it('hands routed preview connectors to ReactFlow while an insertion slot is active', () => { + const { nodes, edges } = makeWireframeFixture(); + const base = deriveSequentialGraph({ nodes, edges, view: 'sequential' }); + const slot = base.projection?.connectors.find( + (connector) => + connector.sourceRowId === WIREFRAME_NODE_IDS.http && + connector.targetRowId === WIREFRAME_NODE_IDS.javascript + )?.slot; + expect(slot).toBeDefined(); + + const { result } = renderHook(() => + useSequentialGraph({ nodes, edges, view: 'sequential', insertSlot: slot }) + ); + const previewEdges = result.current.edges.filter( + (edge) => edge.source === PREVIEW_NODE_ID || edge.target === PREVIEW_NODE_ID + ); + + expect(previewEdges).toHaveLength(2); + expect(previewEdges.every((edge) => edge.data?.preview === true)).toBe(true); + expect(result.current.layout?.positions.get(PREVIEW_NODE_ID)).toBeDefined(); + }); + + it('reuses the connector-edge array across data-only changes but reflects new data on nodes', () => { + const { nodes, edges } = makeWireframeFixture(); + const { result, rerender } = renderHook( + (props: { nodes: Node[]; edges: Edge[] }) => + useSequentialGraph({ nodes: props.nodes, edges: props.edges, view: 'sequential' }), + { initialProps: { nodes, edges } } + ); + + const firstEdges = result.current.edges; + const firstNodes = result.current.nodes; + + // Data-only change: rename one node (structure/fingerprint unchanged). + const renamed = nodes.map((node) => + node.id === WIREFRAME_NODE_IDS.http + ? { ...node, data: { display: { label: 'Renamed' } } } + : node + ); + rerender({ nodes: renamed, edges }); + + // Edges are reference-stable across data-only changes (D12). + expect(result.current.edges).toBe(firstEdges); + // Nodes recompute so the rename is reflected. + expect(result.current.nodes).not.toBe(firstNodes); + const http = result.current.nodes.find((node) => node.id === WIREFRAME_NODE_IDS.http)!; + expect((http.data as { display: { label: string } }).display.label).toBe('Renamed'); + }); + + it('reuses 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..b96f10016 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/useSequentialGraph.ts @@ -0,0 +1,649 @@ +import type { Edge, Node } from '@uipath/apollo-react/canvas/xyflow/react'; +import { useMemo } from 'react'; +import { useSafeLingui } from '../../../i18n'; +import { + DEFAULT_SOURCE_HANDLE_ID, + PREVIEW_NODE_ID, + SEQ_BAR_HEIGHT, + SEQ_BAR_WIDTH, + SEQ_PLACEHOLDER_NODE_TYPE, + SEQ_ROW_GAP, + SEQ_START_NODE_TYPE, +} from '../../constants'; +import type { NodeTypeRegistry } from '../../core'; +import { useOptionalNodeTypeRegistry } from '../../core'; +import type { InstanceDisplayConfig } from '../../schema/node-instance'; +import { resolveDisplay } from '../../utils/manifest-resolver'; +import { sequenceFingerprint } from '../../utils/sequential/fingerprint'; +import { layoutSequence } from '../../utils/sequential/layoutSequence'; +import { projectionWithPreviewRow } from '../../utils/sequential/previewRow'; +import { projectSequence } from '../../utils/sequential/projectSequence'; +import type { + CanvasView, + InsertionSlot, + LayoutSequenceOptions, + SequenceLayout, + SequenceProjection, +} from '../../utils/sequential/sequential.types'; +import { EMPTY_WAYPOINTS } from '../Edges/shared/constants'; +import type { SequentialConnectorData } from './edges/SequentialConnectorEdge.types'; +import { SEQUENTIAL_BAR_HANDLE_IDS } from './nodes'; +import type { SequentialPlaceholderNodeData } from './nodes/SequentialPlaceholderNode'; +import type { SequentialStartNodeData } from './nodes/SequentialStartNode'; +import { + SEQ_CONNECTOR_EDGE_TYPE, + SEQ_PLACEHOLDER_EDGE_ID, + SEQ_PLACEHOLDER_ROW_ID, + SEQ_START_EDGE_ID, + SEQ_START_ROW_ID, +} from './sequentialGraph.constants'; + +/** The translate function `useSafeLingui()._` returns; used to type the aria-label formatter. */ +type TranslateFn = ReturnType['_']; + +const EMPTY_COLLAPSED: ReadonlySet = new Set(); + +/** + * Stable string over a predicate applied to each item, used only as a memo + * guard so the projection recomputes when predicate *results* (not identity) + * change. When the predicate is absent, every item falls back to `whenAbsent`. + */ +function predicateFingerprint( + items: readonly T[], + predicate: ((item: T) => boolean) | undefined, + whenAbsent: 0 | 1 +): string { + return items + .map((item) => `${item.id}:${predicate ? (predicate(item) ? 1 : 0) : whenAbsent}`) + .join('|'); +} + +/** The ids of the two synthetic rows, for filtering them out of change callbacks. */ +export const SEQ_SYNTHETIC_ROW_IDS: ReadonlySet = new Set([ + SEQ_START_ROW_ID, + SEQ_PLACEHOLDER_ROW_ID, +]); + +export interface UseSequentialGraphArgs { + nodes: N[]; + edges: E[]; + /** Only the 'sequential' view derives; 'flow' passes the canonical graph through. */ + view: CanvasView; + collapsedStepIds?: ReadonlySet; + /** Wired onto the synthetic start bar's "Add trigger" button. */ + onAddTrigger?: () => void; + /** Wired onto the terminal placeholder's click (opens Add Node for the tail slot). */ + onPlaceholderAdd?: () => void; + /** Registry-driven predicate excluding artifact/resource edges (see projectSequence). */ + isSequenceEdge?: (edge: E) => boolean; + /** Registry-driven trigger predicate; matching nodes collapse into the synthetic start row. */ + isStartNode?: (node: N) => boolean; + /** Registry-driven predicate preserving empty manifest containers. */ + isContainerNode?: (node: N) => boolean; + resolveBranchLabel?: (nodeId: string, handleId: string) => string; + /** Registry-driven branch-lane handles for a parent node (see projectSequence). */ + getBranchHandles?: (node: N) => { id: string; label: string }[]; + /** Opens the Add Node panel for an empty branch lane's "+ Add step" placeholder. */ + onLaneAdd?: (slot: InsertionSlot) => void; + /** + * The slot whose Add Node panel is currently open, if any. While set, the + * layout re-runs with a synthetic preview row spliced in at the slot, opening + * a one-row gap and re-routing every connector from the shifted geometry. Only + * the layout memo depends on it; the projection memo stays fingerprint-keyed + * (D12), so this never re-projects. + */ + insertSlot?: InsertionSlot; + /** Geometry overrides applied consistently to rows, connectors, gutter, and preview layout. */ + layoutOptions?: LayoutSequenceOptions; +} + +export interface SequentialGraph { + nodes: N[]; + edges: E[]; + projection: SequenceProjection | null; + /** + * The geometry pass backing `nodes`' positions. Exposed so consumers like + * `SequentialGutter` (WS5) can align the step-number gutter to the exact + * same coordinates without re-deriving them or reading node DOM. + */ + layout: SequenceLayout | null; +} + +/** + * Derives the sequential view's node/edge arrays from the canonical graph (D4): + * - each projected row becomes a clone of its canonical node that KEEPS its real + * `type` (so BaseNode resolves the real manifest and the bar looks identical to + * the card), takes its position from `layoutSequence`, is stamped + * `width = SEQ_BAR_WIDTH`, flattened (`parentId` cleared so the absolute layout + * positions are honored), made non-draggable, and hidden when a collapsed + * ancestor parks it. `data` and `selected` are passed through by reference so + * execution/validation contexts, memoization, and selection all keep working; + * - a synthetic "Workflow start" bar is injected above the first row and a + * terminal "Add step" placeholder below the last top-level row (both view-only, + * filtered from the change callbacks); + * - each projection connector becomes a reference-stable + * {@link SequentialConnectorData} edge, plus synthetic connectors joining the + * start/placeholder bars. + * + * This is a PURE function so the round-trip/derivation tests exercise it without + * mounting xyflow. The hook {@link useSequentialGraph} memoizes it per D12. + */ +export function deriveSequentialGraph( + args: UseSequentialGraphArgs +): SequentialGraph { + const { nodes, edges } = args; + const collapsed = args.collapsedStepIds ?? EMPTY_COLLAPSED; + const projection = projectSequence(nodes, edges, { + collapsedStepIds: collapsed, + isSequenceEdge: args.isSequenceEdge as ((edge: Edge) => boolean) | undefined, + isStartNode: args.isStartNode as ((node: Node) => boolean) | undefined, + isContainerNode: args.isContainerNode as ((node: Node) => boolean) | undefined, + resolveBranchLabel: args.resolveBranchLabel, + getBranchHandles: args.getBranchHandles as + | ((node: Node) => { id: string; label: string }[]) + | undefined, + }); + const renderedProjection = args.insertSlot + ? projectionWithPreviewRow(projection, args.insertSlot) + : projection; + const layout = layoutSequence(renderedProjection, args.layoutOptions); + + const nodesById = new Map(nodes.map((node) => [node.id, node] as const)); + const seqNodes = buildSequentialNodes( + projection, + layout, + nodesById, + { + onAddTrigger: args.onAddTrigger, + onPlaceholderAdd: args.onPlaceholderAdd, + onLaneAdd: args.onLaneAdd, + }, + args.layoutOptions + ); + const seqEdges = buildSequentialEdges(renderedProjection, layout); + + return { + nodes: seqNodes as unknown as N[], + edges: seqEdges as unknown as E[], + projection, + layout, + }; +} + +interface SyntheticCallbacks { + onAddTrigger?: () => void; + onPlaceholderAdd?: () => void; + /** Wired onto each empty branch-lane placeholder's click, with its own slot. */ + onLaneAdd?: (slot: InsertionSlot) => void; +} + +function buildSequentialNodes( + projection: SequenceProjection, + layout: SequenceLayout, + nodesById: ReadonlyMap, + callbacks: SyntheticCallbacks, + layoutOptions?: LayoutSequenceOptions +): Node[] { + const barWidth = layoutOptions?.barWidth ?? SEQ_BAR_WIDTH; + const barHeight = layoutOptions?.barHeight ?? SEQ_BAR_HEIGHT; + const rowGap = layoutOptions?.rowGap ?? SEQ_ROW_GAP; + const pitch = barHeight + rowGap; + const clones: Node[] = []; + const orphanClones: Node[] = []; + let firstTopY: number | undefined; + let firstOrphanY: number | undefined; + // Lowest visible, non-orphan row (any depth), so the terminal placeholder can + // sit below the WHOLE stack rather than just below the last top-level row. + let maxVisibleY: number | undefined; + + for (const row of projection.rows) { + // Empty branch-lane placeholder: a synthetic dashed "+ Add step" bar with no + // canonical node. Rendered via the placeholder node type, entered mid-left by + // its branch-entry connector (Part A), and clicking it appends the first node + // into that lane via the carried slot. + if (row.lanePlaceholder) { + const laneSlot = row.lanePlaceholder; + const onLaneAdd = callbacks.onLaneAdd; + clones.push({ + id: row.nodeId, + type: SEQ_PLACEHOLDER_NODE_TYPE, + position: layout.positions.get(row.nodeId) ?? { x: 0, y: 0 }, + width: barWidth, + height: 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, + } 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. + const placeholderY = + firstOrphanY !== undefined ? firstOrphanY - pitch : (maxVisibleY ?? -pitch) + 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: barHeight, + draggable: false, + selectable: callbacks.onPlaceholderAdd !== undefined, + data: { onAdd: callbacks.onPlaceholderAdd } satisfies SequentialPlaceholderNodeData, + }; + + return [startNode, ...clones, placeholderNode, ...orphanClones]; +} + +function buildSequentialEdges(projection: SequenceProjection, layout: SequenceLayout): Edge[] { + const edges: Edge[] = projection.connectors.map((connector) => { + const isPreviewConnector = + connector.sourceRowId === PREVIEW_NODE_ID || connector.targetRowId === PREVIEW_NODE_ID; + const data: SequentialConnectorData = { + kind: connector.kind, + label: connector.label, + waypoints: layout.connectorWaypoints.get(connector.id) ?? EMPTY_WAYPOINTS, + // The preview connectors retain the semantic slot for canonical handle + // resolution below, but must not offer another insert button while the + // Add Node panel is already open. + slot: isPreviewConnector ? undefined : connector.slot, + preview: isPreviewConnector || undefined, + hideArrowHead: connector.kind === 'goto', + }; + return { + id: connector.id, + source: connector.sourceRowId, + target: connector.targetRowId, + sourceHandle: + connector.sourceRowId === PREVIEW_NODE_ID + ? 'output' + : isPreviewConnector + ? (connector.slot?.source?.handleId ?? SEQUENTIAL_BAR_HANDLE_IDS.source) + : 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' + : isPreviewConnector + ? (connector.slot?.target?.handleId ?? SEQUENTIAL_BAR_HANDLE_IDS.target) + : connector.kind === 'branch-entry' + ? SEQUENTIAL_BAR_HANDLE_IDS.branchTarget + : SEQUENTIAL_BAR_HANDLE_IDS.target, + type: SEQ_CONNECTOR_EDGE_TYPE, + data, + ...(isPreviewConnector + ? { + style: { + opacity: 0.8, + stroke: 'var(--canvas-selection-indicator)', + strokeWidth: 2, + }, + } + : {}), + }; + }); + + // Synthetic joins: start -> first top row, last top row -> placeholder. When + // there are no rows the two synthetic bars connect directly. + const topRows = projection.rows.filter((row) => row.depth === 0 && !row.orphan); + const firstTop = topRows[0]; + const lastVisibleTop = [...topRows].reverse().find((row) => row.visible); + + const syntheticData = (hideArrowHead = true): SequentialConnectorData => ({ + kind: 'step', + waypoints: EMPTY_WAYPOINTS, + hideArrowHead, + }); + + const syntheticPreviewData = (hideArrowHead = true): SequentialConnectorData => ({ + ...syntheticData(hideArrowHead), + preview: true, + ignorePreviewConnection: true, + }); + + const previewIsFirst = firstTop?.nodeId === PREVIEW_NODE_ID; + const headSlot: InsertionSlot | undefined = + firstTop && !previewIsFirst + ? { id: `slot:head:${firstTop.nodeId}`, target: { nodeId: firstTop.nodeId } } + : undefined; + edges.push({ + id: SEQ_START_EDGE_ID, + source: SEQ_START_ROW_ID, + target: firstTop?.nodeId ?? SEQ_PLACEHOLDER_ROW_ID, + sourceHandle: SEQUENTIAL_BAR_HANDLE_IDS.source, + targetHandle: previewIsFirst ? 'input' : SEQUENTIAL_BAR_HANDLE_IDS.target, + type: SEQ_CONNECTOR_EDGE_TYPE, + data: previewIsFirst + ? syntheticPreviewData(false) + : { ...syntheticData(firstTop === undefined), slot: headSlot }, + ...(previewIsFirst + ? { + style: { + opacity: 0.8, + stroke: 'var(--canvas-selection-indicator)', + strokeWidth: 2, + }, + } + : {}), + }); + + if (lastVisibleTop) { + const previewIsLast = lastVisibleTop.nodeId === PREVIEW_NODE_ID; + edges.push({ + id: SEQ_PLACEHOLDER_EDGE_ID, + source: lastVisibleTop.nodeId, + target: SEQ_PLACEHOLDER_ROW_ID, + sourceHandle: previewIsLast ? 'output' : SEQUENTIAL_BAR_HANDLE_IDS.source, + targetHandle: SEQUENTIAL_BAR_HANDLE_IDS.target, + type: SEQ_CONNECTOR_EDGE_TYPE, + data: previewIsLast ? syntheticPreviewData() : syntheticData(), + ...(previewIsLast + ? { + style: { + opacity: 0.8, + stroke: 'var(--canvas-selection-indicator)', + strokeWidth: 2, + }, + } + : {}), + }); + } + + return edges; +} + +/** + * Stamps each numbered row's clone with `node.ariaLabel` (xyflow renders this + * as `aria-label` on the node's DOM wrapper): "Step {n} of {total}: {label}" + * (D8). `total` counts every numbered row regardless of visibility, so it + * never shifts when a container collapses (D7's stable-numbering guarantee + * extends to the announced total). The label is resolved through the node + * type registry the same way `BaseNode` resolves its printed label + * (`resolveDisplay(manifest.display, {display: node.data.display})`), so the + * announced name matches what's on the bar; a registry-less host (or an + * unrecognized type) still gets a readable fallback ("Unknown Node") instead + * of a blank label. Synthetic rows (start bar, placeholder) are left + * untouched -- they already carry their own visible, non-templated text. + * + * This only runs inside the {@link useSequentialGraph} hook (which has React + * context for the registry + lingui), not {@link deriveSequentialGraph} (the + * pure function exercised directly by tests without mounting either provider). + */ +function stampStepAriaLabels( + nodes: Node[], + projection: SequenceProjection, + registry: NodeTypeRegistry | null, + translate: TranslateFn +): Node[] { + const stepNumberByRowId = new Map(); + let total = 0; + for (const row of projection.rows) { + if (row.stepNumber === undefined) continue; + stepNumberByRowId.set(row.nodeId, row.stepNumber); + total += 1; + } + if (total === 0) return nodes; + + return nodes.map((node) => { + const stepNumber = stepNumberByRowId.get(node.id); + if (stepNumber === undefined) return node; + + const manifest = registry?.getManifest(node.type ?? ''); + const display = resolveDisplay(manifest?.display, { + display: (node.data as { display?: InstanceDisplayConfig } | undefined)?.display, + }); + + const ariaLabel = translate({ + id: 'sequential-canvas.step.aria-label', + message: 'Step {stepNumber} of {total}: {label}', + values: { stepNumber, total, label: display.label }, + }); + + return { ...node, ariaLabel }; + }); +} + +/** + * 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, + isStartNode, + isContainerNode, + resolveBranchLabel, + getBranchHandles, + onLaneAdd, + insertSlot, + layoutOptions, + } = args; + + const { _ } = useSafeLingui(); + const registry = useOptionalNodeTypeRegistry(); + + const collapsed = collapsedStepIds ?? EMPTY_COLLAPSED; + const fingerprint = useMemo( + () => sequenceFingerprint(nodes, edges, collapsed), + [nodes, edges, collapsed] + ); + // These fingerprints only guard the sequential projection memo below, which + // short-circuits to null in flow view; skip the full-graph passes there. + const inSequentialView = view === 'sequential'; + const labelFingerprint = useMemo( + () => + inSequentialView + ? edges + .map((edge) => { + const handleId = edge.sourceHandle ?? DEFAULT_SOURCE_HANDLE_ID; + const resolved = resolveBranchLabel?.(edge.source, handleId); + const fallback = (edge.data as { label?: string | null } | undefined)?.label; + // NUL separates id from label so neither can forge a collision. + return `${edge.id}${resolved ?? fallback ?? ''}`; + }) + .sort() + .join('|') + : '', + [inSequentialView, edges, resolveBranchLabel] + ); + const edgeInclusionFingerprint = useMemo( + () => (inSequentialView ? predicateFingerprint(edges, isSequenceEdge, 1) : ''), + [inSequentialView, edges, isSequenceEdge] + ); + const 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; + return projectSequence(nodes, edges, { + collapsedStepIds: collapsed, + isSequenceEdge: isSequenceEdge as ((edge: Edge) => boolean) | undefined, + isStartNode: isStartNode as ((node: Node) => boolean) | undefined, + isContainerNode: isContainerNode as ((node: Node) => boolean) | undefined, + resolveBranchLabel, + getBranchHandles: getBranchHandles as + | ((node: Node) => { id: string; label: string }[]) + | undefined, + }); + }, [ + fingerprint, + labelFingerprint, + edgeInclusionFingerprint, + 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 + ); + return stampStepAriaLabels(built, projection, registry, _); + }, [ + 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 + * `