Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions packages/apollo-react/src/canvas/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,3 +195,90 @@ If you already use `@uipath/apollo-react/canvas/styles/variables.css` with shado
**Cause:** The Tailwind base layer includes `* { border-color: var(--color-border-de-emp) }` and `body { background-color: var(--background) }`. These may conflict with existing component styles.

**Fix:** These rules are in `@layer base`, so they lose to un-layered styles. If conflicts persist in Shadow DOM, override with more specific selectors in your injected CSS.

---

## Sequential view

`SequentialCanvas` is an n8n/Zapier-style vertical projection of the same flow graph, built on the existing `BaseCanvas`. It reuses the same node manifests, icons, execution status, validation badges, and theming as the flow view; only the layout is different. A `ViewSwitcher` lets the host toggle a canvas between the free-form `flow` layout and the vertical `sequential` layout.

### Projection model

The consumer keeps one canonical `nodes`/`edges` array. `SequentialCanvas` derives vertical geometry from graph structure and never writes that geometry back. Entering Flow through `prepareCanvasViewTransition` computes a deterministic left-to-right layout and updates only canonical presentation fields (`position` and container dimensions); ids, data, containment, handles, and edges are unchanged. Nodes added in Sequential are normalized during the same transition.

Sequential is not a symmetric conversion for every graph. `prepareCanvasViewTransition('sequential', ...)` returns a compatibility report with a `level` and an `editable` flag, and the guidance is three-way:

1. **Editable.** `level: 'exact'`, and also `level: 'degraded'` when the only issues are `multiple-roots` or `orphan`. These graphs render correctly and are recoverable by ordinary editing, so keep them fully editable. Locking the view as soon as a user has two disconnected steps would lock them out of the only view that flags the problem.
2. **Read-only.** A report containing a `cycle` or `unstructured-merge` issue. The slot-based mutation operations cannot express a safe splice across a `goto` connector, because a `goto` carries no insertion slot.
3. **Not supported.** `level: 'unsupported'`, which means malformed containment (a duplicate node id, a missing parent, or a parent cycle).

The component does not enforce any of this. The host reads `report.editable` and chooses the `mode` it passes to `SequentialCanvas`, which is what the example below does. Presentation-only nodes and edges can be excluded from the projection and preserved in the canonical graph.

### Availability

Everything ships from `@uipath/apollo-react/canvas`, the same entry point as the rest of the canvas. The component surface (`SequentialCanvas`, `SequentialCanvasProps`, `prepareCanvasViewTransition`, `ViewSwitcher`, `useCanvasViewMode`, `SequentialViewProvider`) and the pure projection engine (`analyzeSequentialCompatibility`, `projectSequence`, `layoutSequence`, the report types) are both re-exported from it, and both follow the package's normal semver.

One caveat to plan around: the sequential view is pre-GA. Its documented v1 limitations (see "Degraded graphs" below, plus bare branch owners that cannot be moved and loop-close edges that are not relocated) mean the props are still likely to change, so pin the package version if you adopt it early and read the release notes before upgrading. If you only need to reason about a graph rather than render it, depend on the projection engine and skip the component entirely: it is pure, has no xyflow or registry dependency, and is the more settled half of the two.

### Minimal usage

```tsx
import { useState } from 'react';
import { applyEdgeChanges, applyNodeChanges } from '@uipath/apollo-react/canvas/xyflow/react';
import {
prepareCanvasViewTransition,
SequentialCanvas,
useCanvasViewMode,
ViewSwitcher,
} from '@uipath/apollo-react/canvas';

function MyFlow({ initialNodes, initialEdges }) {
const [view, setView] = useCanvasViewMode('my-flow.view');
const [nodes, setNodes] = useState(initialNodes);
const [edges, setEdges] = useState(initialEdges);
const [report, setReport] = useState(undefined);

const changeView = (nextView) => {
const transition = prepareCanvasViewTransition(nextView, nodes, edges);
setNodes(transition.nodes);
// Only populated when entering sequential. Recompute it whenever the graph
// changes while sequential is on screen, or the advice below goes stale.
setReport(transition.sequentialCompatibility);
setView(nextView);
};

// The component never locks itself. The host decides, from `editable`.
const mode = view === 'sequential' && report && !report.editable ? 'readonly' : 'design';

return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<ViewSwitcher value={view} onChange={changeView} />
<SequentialCanvas
view={view}
mode={mode}
nodes={nodes}
edges={edges}
onNodesChange={(changes) => setNodes((current) => applyNodeChanges(changes, current))}
onEdgesChange={(changes) => setEdges((current) => applyEdgeChanges(changes, current))}
/>
</div>
);
}
```

`SequentialCanvas` supplies its own `ReactFlowProvider` and keeps one `BaseCanvas` mounted while `view` changes. For a worked example, including per-view pan save/restore and a live compatibility banner, see `SequentialCanvasStoryHarness` and the `Wireframe` story. Note that pan is saved per view but zoom is not: it carries across the toggle from the view being left, so switching views reframes the graph without changing how far the user is zoomed in.

### Degraded graphs

Not every graph is a clean, structured sequence. The projection degrades gracefully instead of failing:

| Shape | Issue code | Rendering | Still editable? |
|---|---|---|---|
| Multiple roots or disconnected components | `multiple-roots` | Stacked lanes ordered by flow-view y | Yes |
| Orphans (no sequence edges at all) | `orphan` | A de-emphasized trailing section after the terminal placeholder | Yes |
| Unstructured merge (a node with more than one incoming edge) | `unstructured-merge` | Placed under its first incomer; the extra incoming edge draws a dashed goto connector | No |
| Cycles other than a loop's `loopBack` handle | `cycle` | A dashed, arrowless goto connector closes the cycle | No |

Every case renders something rather than crashing or dropping a node: the cycle guard prevents infinite recursion, and unreachable or disconnected nodes are always appended as trailing rows. The toggle is never blocked, and rendering is independent of editability. A read-only degraded graph is still worth showing, since seeing the dashed goto connector is how a user finds the structure to fix.

Both CSS delivery patterns described earlier in this guide (PostCSS scanning and precompiled Shadow DOM injection) cover the sequential view's markup with no extra configuration, since it is built entirely from Tailwind utility classes scanned from this package's `dist/canvas`.
198 changes: 25 additions & 173 deletions packages/apollo-react/src/canvas/components/BaseNode/BaseNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import type { Node, NodeProps } from '@uipath/apollo-react/canvas/xyflow/react';
import {
Position,
useReactFlow,
useStore,
useUpdateNodeInternals,
} from '@uipath/apollo-react/canvas/xyflow/react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
Expand All @@ -23,37 +22,21 @@ import {
NODE_INNER_RADIUS_RATIO,
NODE_INNER_SHAPE_RATIO,
} from '../../constants';
import { useNodeTypeRegistry } from '../../core';
import { useElementValidationStatus, useNodeExecutionState } from '../../hooks';
import type { NodeShape } from '../../schema';
import type { HandleGroupManifest } from '../../schema/node-definition';
import { resolveAdornments } from '../../utils/adornment-resolver';
import { CanvasIcon, getIcon } from '../../utils/icon-registry';
import { resolveDisplay, resolveHandles } from '../../utils/manifest-resolver';
import { selectIsConnecting } from '../../utils/NodeUtils';
import { CanvasIcon } from '../../utils/icon-registry';
import { areNodePropsEqualIgnoringPosition } from '../../utils/nodePropsEqual';
import { resolveToolbar } from '../../utils/toolbar-resolver';
import { useBaseCanvasMode } from '../BaseCanvas/BaseCanvasModeProvider';
import { useCanvasTheme } from '../BaseCanvas/CanvasThemeContext';
import { useConnectedHandles } from '../BaseCanvas/ConnectedHandlesContext';
import { useSelectionState } from '../BaseCanvas/SelectionStateContext';
import type { HandleActionEvent } from '../ButtonHandle/ButtonHandle';
import { SmartHandle, SmartHandleProvider } from '../ButtonHandle/SmartHandle';
import { useButtonHandles } from '../ButtonHandle/useButtonHandles';
import { InitialsBadge } from '../shared/InitialsBadge';
import { NodeToolbar } from '../Toolbar';
import type {
BaseNodeData,
FooterVariant,
NodeAdornments,
NodeStatusContext,
} from './BaseNode.types';
import type { BaseNodeData, FooterVariant } from './BaseNode.types';
import { BaseBadgeSlot } from './BaseNodeBadgeSlot';
import { useBaseNodeOverrideConfig } from './BaseNodeConfigContext';
import { BaseContainer } from './BaseNodeContainer';
import { BaseInnerShape } from './BaseNodeInnerShape';
import { MissingManifestNode } from './BaseNodeMissingManifest';
import { NodeLabel } from './NodeLabel';
import { useBaseNodePresentation } from './useBaseNodePresentation';

const getContainerWidth = (shape: NodeShape | undefined, width: number | undefined) => {
const defaultWidth = shape === 'rectangle' ? DEFAULT_RECTANGLE_NODE_WIDTH : DEFAULT_NODE_SIZE;
Expand Down Expand Up @@ -81,172 +64,63 @@ const getIntrinsicHeight = (
return NODE_HEIGHT_DEFAULT;
};

const BaseNodeComponent = (props: NodeProps<Node<BaseNodeData>>) => {
const { type, data, selected, id, dragging, width, parentId } = props;
export type BaseNodeProps = NodeProps<Node<BaseNodeData>>;

// Read runtime configuration from context (provided by parent node components)
const BaseNodeComponent = (props: BaseNodeProps) => {
const { type, data, selected, id, dragging, width, parentId } = props;
const presentation = useBaseNodePresentation({ type, data, selected, id, dragging });
const {
adornments,
display,
executionStatus,
handleConfigurations,
icon: Icon,
manifest,
mode,
multipleNodesSelected,
onLabelChange: handleLabelChange,
toolbarConfig,
validationState,
} = presentation;
const {
onHandleAction: onHandleActionProp,
onHandleMouseEnter: onHandleMouseEnterProp,
onHandleMouseLeave: onHandleMouseLeaveProp,
onActionNeeded,
shouldShowAddButtonFn: shouldShowAddButtonFnProp,
shouldShowButtonHandleNotchesFn: shouldShowButtonHandleNotchesFnProp,
toolbarConfig: toolbarConfigProp,
handleConfigurations: handleConfigurationsProp,
adornments: adornmentsProp,
suggestionType,
disabled,
executionStatusOverride,
labelTooltip,
labelBackgroundColor,
footerVariant,
footerComponent,
subLabelComponent,
iconComponent,
} = useBaseNodeOverrideConfig();
} = presentation.overrideConfig;

const updateNodeInternals = useUpdateNodeInternals();
const { updateNodeData, updateNode, getNode } = useReactFlow();
const { updateNode, getNode } = useReactFlow();
const containerRef = useRef<HTMLDivElement>(null);
const [isHovered, setIsHovered] = useState(false);
const [isFocused, setIsFocused] = useState(false);

// Height is driven by computedHeight (below), a pure function of handle count/footer.
// It never reads the measured height, so writing it back can't loop.

// Get execution status from external source
const executionState = useNodeExecutionState(id);
const validationState = useElementValidationStatus(id);
const nodeTypeRegistry = useNodeTypeRegistry();
const { mode } = useBaseCanvasMode();

// Use context for connected handles - O(1) lookup instead of O(edges) per node
const connectedHandleIds = useConnectedHandles(id);

const isConnecting = useStore(selectIsConnecting);
const { multipleNodesSelected } = useSelectionState();

const { isDarkMode } = useCanvasTheme();

// Get manifest and resolve with instance data
const manifest = useMemo(() => nodeTypeRegistry.getManifest(type), [type, nodeTypeRegistry]);

const statusContext: NodeStatusContext = useMemo(
() => ({
nodeId: id,
executionState: executionStatusOverride ?? executionState,
validationState,
isConnecting,
isSelected: selected,
isDragging: dragging,
mode,
}),
[
id,
executionStatusOverride,
executionState,
validationState,
isConnecting,
selected,
dragging,
mode,
]
);
const { isConnecting } = presentation;

// Callbacks: Use props only (no longer in data)
const onHandleActionCallback = onHandleActionProp;
const shouldShowAddButtonFn = shouldShowAddButtonFnProp;
const shouldShowButtonHandleNotchesFn = shouldShowButtonHandleNotchesFnProp;

// Use executionStatusOverride if provided, otherwise use hook value
const executionStatus =
executionStatusOverride ??
(typeof executionState === 'string' ? executionState : executionState?.status);

const display = useMemo(
() => resolveDisplay(manifest?.display, { ...data, nodeId: id }),
[manifest, data, id]
);

// Drillable (manifest) or collapsed (instance data) nodes render a decorative
// stacked layer to signal they stand in for more than themselves.
const isStacked = Boolean(manifest?.drillable || data?.isCollapsed);

// Icon resolution: component prop > icon string > initials badge fallback.
const Icon = useMemo(() => {
if (iconComponent !== undefined) {
return iconComponent;
}
if (display.icon) {
const IconComponent = getIcon(display.icon);
return IconComponent ? <IconComponent /> : null;
}
return <InitialsBadge name={display.label} size="var(--icon-size)" />;
}, [iconComponent, display.icon, display.label]);

// Resolve handles: context override > data override > manifest default
const handleConfigurations = useMemo((): HandleGroupManifest[] => {
// Priority 1: Context override (runtime configuration from parent wrapper components)
if (handleConfigurationsProp && Array.isArray(handleConfigurationsProp)) {
return handleConfigurationsProp;
}

// Priority 2: Per-instance override via node data
const dataHandleConfigs = (data as Record<string, unknown>)?.handleConfigurations as
| HandleGroupManifest[]
| undefined;
if (dataHandleConfigs && Array.isArray(dataHandleConfigs)) {
return dataHandleConfigs;
}

// Priority 3: Manifest default
if (!manifest) return [];
// Pass nodeId and collapsed for collapse state lookup
const resolved = resolveHandles(manifest.handleConfiguration, { ...data, nodeId: id });

// Convert resolved handles to HandleGroupManifest format for ButtonHandle
return resolved.map((group) => ({
position: group.position,
handles: group.handles.map((h) => ({
id: h.id,
type: h.type,
handleType: h.handleType,
label: h.label,
visible: h.visible,
showButton: h.showButton,
labelVisibility: h.labelVisibility,
constraints: h.constraints,
})),
visible: group.visible,
}));
}, [handleConfigurationsProp, manifest, data, id]);

// Toolbar config resolution with priority: props > manifest
const toolbarConfig = useMemo(() => {
// Priority 1: Prop override (runtime callbacks)
// - undefined: use manifest default
// - null: explicitly no toolbar
// - object: use provided config
if (toolbarConfigProp !== undefined) {
return toolbarConfigProp === null ? undefined : toolbarConfigProp;
}

// Priority 2: Manifest default
return manifest ? resolveToolbar(manifest, statusContext) : undefined;
}, [toolbarConfigProp, manifest, statusContext]);

// Adornments resolution: use default resolver, then override with props if provided
const adornments: NodeAdornments = useMemo(() => {
const adornmentsFromProps = adornmentsProp ?? {};
const adornmentsFromResolver = resolveAdornments(statusContext);

return {
...adornmentsFromResolver,
...adornmentsFromProps,
};
}, [adornmentsProp, statusContext]);

// Computed node height: max of the handle-count floor and the intrinsic default/footer
// height. Pure (never reads the measured `height`); written to node.height below as
// the authoritative size, so node content is expected to fit within it.
Expand Down Expand Up @@ -289,9 +163,7 @@ const BaseNodeComponent = (props: NodeProps<Node<BaseNodeData>>) => {
const displayBackground = display.background;
const displayColor = display.color;
const displayShadow = display.shadow ?? true;
const displayIconBackground = isDarkMode
? (display.iconBackgroundDark ?? display.iconBackground)
: display.iconBackground;
const displayIconBackground = presentation.iconBackground;

// Display customization from props (not data)
const displayLabelTooltip = labelTooltip;
Expand Down Expand Up @@ -401,24 +273,6 @@ const BaseNodeComponent = (props: NodeProps<Node<BaseNodeData>>) => {
const handleFocus = useCallback(() => setIsFocused(true), []);
const handleBlur = useCallback(() => setIsFocused(false), []);

const handleLabelChange = useCallback(
(values: { label: string; subLabel: string }) => {
const newDisplay = { ...data.display };
// Ensure all label fields are updated or removed appropriately.
for (const labelKey of Object.keys(values) as (keyof typeof values)[]) {
if (values[labelKey]) {
newDisplay[labelKey] = values[labelKey];
} else {
delete newDisplay[labelKey];
}
}
updateNodeData(id, {
display: newDisplay,
});
},
[id, data.display, updateNodeData]
);

// Calculate if notches should be shown (when node is hovered or selected)
// Use custom function if provided, otherwise use default logic
const showNotches = shouldShowButtonHandleNotchesFn
Expand Down Expand Up @@ -464,7 +318,7 @@ const BaseNodeComponent = (props: NodeProps<Node<BaseNodeData>>) => {
}
}
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
Expand Down Expand Up @@ -596,7 +450,6 @@ const BaseNodeComponent = (props: NodeProps<Node<BaseNodeData>>) => {

if (!manifest) {
return (
// biome-ignore lint/a11y/noStaticElementInteractions: canvas node interaction
<div
ref={containerRef}
className="relative"
Expand All @@ -617,7 +470,6 @@ const BaseNodeComponent = (props: NodeProps<Node<BaseNodeData>>) => {
}

const nodeContent = (
// biome-ignore lint/a11y/noStaticElementInteractions: canvas node interaction
<div
ref={containerRef}
className="relative"
Expand Down
Loading
Loading