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
68 changes: 68 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,71 @@ If you already use `@uipath/apollo-react/canvas/styles/variables.css` with shado
**Cause:** The Tailwind base layer includes `* { border-color: var(--color-border-de-emp) }` and `body { background-color: var(--background) }`. These may conflict with existing component styles.

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

---

## Sequential view

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

### Projection model

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

Sequential is not a symmetric conversion for every graph. `prepareCanvasViewTransition('sequential', ...)` returns a compatibility report: exact graphs are safely editable, degraded graphs (cycles, multiple roots, unstructured merges, orphans) should be presented read-only, and malformed containment is unsupported. Presentation-only nodes and edges can be excluded from the projection and preserved in the canonical graph.

### Minimal usage

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

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

const changeView = (nextView) => {
const transition = prepareCanvasViewTransition(nextView, nodes, edges);
setNodes(transition.nodes);
setView(nextView);
};

return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<ViewSwitcher value={view} onChange={changeView} />
<SequentialCanvas
view={view}
mode="design"
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 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`.
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