Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export { BaseCanvas } from './BaseCanvas';
export * from './BaseCanvas.constants';
export * from './BaseCanvas.hooks';
export * from './BaseCanvas.types';
export * from './BaseCanvasModeProvider';
export * from './CanvasBackground';
export * from './CanvasProviders';
export * from './CanvasThemeContext';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,13 @@ const DEFAULT_MANIFEST = {
} as const;

// Hoisted mocks — available inside vi.mock factories
const { mockUpdateNode, mockHandleConfigs, mockManifest } = vi.hoisted(() => ({
const {
mockUpdateNode,
mockHandleConfigs,
mockManifest,
mockUseButtonHandles,
mockOverrideConfig,
} = vi.hoisted(() => ({
mockUpdateNode: vi.fn(),
mockHandleConfigs: { current: undefined as HandleGroupManifest[] | undefined },
mockManifest: {
Expand All @@ -19,6 +25,9 @@ const { mockUpdateNode, mockHandleConfigs, mockManifest } = vi.hoisted(() => ({
handleConfiguration: [],
} as Record<string, unknown>,
},
// biome-ignore lint/suspicious/noExplicitAny: hook receives a broad typed options object
mockUseButtonHandles: vi.fn() as any,
mockOverrideConfig: { current: {} as Record<string, unknown> },
}));

// xyflow is globally mocked in canvas-mocks.ts; extend with test-specific overrides.
Expand Down Expand Up @@ -50,13 +59,21 @@ vi.mock('../BaseCanvas/SelectionStateContext', () => ({
vi.mock('../BaseCanvas/CanvasThemeContext', () => ({
useCanvasTheme: () => ({ isDarkMode: false }),
}));
vi.mock('../ButtonHandle/useButtonHandles', () => ({ useButtonHandles: () => null }));
vi.mock('../ButtonHandle/useButtonHandles', () => ({
useButtonHandles: (opts: unknown) => {
mockUseButtonHandles(opts);
return null;
},
}));
vi.mock('../ButtonHandle/SmartHandle', () => ({
SmartHandle: () => null,
SmartHandleProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock('./BaseNodeConfigContext', () => ({
useBaseNodeOverrideConfig: () => ({ handleConfigurations: mockHandleConfigs.current }),
useBaseNodeOverrideConfig: () => ({
handleConfigurations: mockHandleConfigs.current,
...mockOverrideConfig.current,
}),
}));
vi.mock('../../utils/adornment-resolver', () => ({ resolveAdornments: () => ({}) }));
vi.mock('../../utils/toolbar-resolver', () => ({ resolveToolbar: () => undefined }));
Expand Down Expand Up @@ -122,8 +139,10 @@ const makeHandles = (position: Position, count: number): HandleGroupManifest[] =
describe('BaseNode', () => {
afterEach(() => {
mockUpdateNode.mockClear();
mockUseButtonHandles.mockClear();
mockHandleConfigs.current = undefined;
mockManifest.current = { ...DEFAULT_MANIFEST };
mockOverrideConfig.current = {};
});

describe('Height computation', () => {
Expand Down Expand Up @@ -283,4 +302,38 @@ describe('BaseNode', () => {
expect(screen.queryByText('M', { selector: '[aria-hidden="true"]' })).not.toBeInTheDocument();
});
});

// BaseNode forwards the consumer-facing `onHandleMouseEnter`/`onHandleMouseLeave`
// from `BaseNodeOverrideConfigProvider` into `useButtonHandles` as
// `handleMouseEnter`/`handleMouseLeave`. Trigger those by reaching into the
// captured hook arguments and invoking them with a synthetic payload.
describe('Handle hover handlers', () => {
it('forwards onHandleMouseEnter/onHandleMouseLeave overrides and fires with the payload', () => {
const onHandleMouseEnter = vi.fn();
const onHandleMouseLeave = vi.fn();
mockOverrideConfig.current = { onHandleMouseEnter, onHandleMouseLeave };

render(<BaseNode {...defaultProps} />);

expect(mockUseButtonHandles).toHaveBeenCalled();
const opts = mockUseButtonHandles.mock.calls.at(-1)?.[0] as {
handleMouseEnter?: (e: unknown) => void;
handleMouseLeave?: (e: unknown) => void;
};
expect(opts.handleMouseEnter).toBe(onHandleMouseEnter);
expect(opts.handleMouseLeave).toBe(onHandleMouseLeave);

const payload = {
handleId: 'output',
nodeId: 'test-node',
handleType: 'output',
position: Position.Right,
};
opts.handleMouseEnter?.(payload);
opts.handleMouseLeave?.(payload);

expect(onHandleMouseEnter).toHaveBeenCalledWith(payload);
expect(onHandleMouseLeave).toHaveBeenCalledWith(payload);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ const BaseNodeComponent = (props: NodeProps<Node<BaseNodeData>>) => {
// Read runtime configuration from context (provided by parent node components)
const {
onHandleAction: onHandleActionProp,
onHandleMouseEnter: onHandleMouseEnterProp,
onHandleMouseLeave: onHandleMouseLeaveProp,
shouldShowAddButtonFn: shouldShowAddButtonFnProp,
shouldShowButtonHandleNotchesFn: shouldShowButtonHandleNotchesFnProp,
toolbarConfig: toolbarConfigProp,
Expand Down Expand Up @@ -480,6 +482,8 @@ const BaseNodeComponent = (props: NodeProps<Node<BaseNodeData>>) => {
handleConfigurations,
shouldShowHandles,
handleAction,
handleMouseEnter: onHandleMouseEnterProp,
handleMouseLeave: onHandleMouseLeaveProp,
nodeId: id,
selected: selected ?? false,
hovered: isHovered,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createContext, useContext } from 'react';
import type { HandleGroupManifest } from '../../schema/node-definition';
import type { ElementStatusValues } from '../../types/execution';
import type { HandleActionEvent } from '../ButtonHandle/ButtonHandle';
import type { HandleActionEvent, HandleMouseEvent } from '../ButtonHandle/ButtonHandle';
import type { NodeToolbarConfig } from '../Toolbar';
import type { FooterVariant, NodeAdornments } from './BaseNode.types';

Expand All @@ -20,6 +20,10 @@ import type { FooterVariant, NodeAdornments } from './BaseNode.types';
export interface BaseNodeOverrideConfig {
// Callbacks (Runtime Behavior)
onHandleAction?: (event: HandleActionEvent) => void;
/** Fired when the cursor enters a source handle's inline add button. */
onHandleMouseEnter?: (event: HandleMouseEvent) => void;
/** Fired when the cursor leaves a source handle's inline add button. */
onHandleMouseLeave?: (event: HandleMouseEvent) => void;
shouldShowAddButtonFn?: (opts: { showAddButton: boolean; selected: boolean }) => boolean;
shouldShowButtonHandleNotchesFn?: (opts: {
isConnecting: boolean;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Position } from '@uipath/apollo-react/canvas/xyflow/react';
import { describe, expect, it, vi } from 'vitest';
import type { ButtonHandleConfig } from './ButtonHandle';
import { canvasEventBus } from '../../utils/CanvasEventBus';
import type { ButtonHandleConfig, HandleMouseEvent } from './ButtonHandle';
import { ButtonHandles } from './ButtonHandle';

// Mock @xyflow/react Handle component
Expand Down Expand Up @@ -380,4 +381,138 @@ describe('ButtonHandles', () => {
expect(handleElements[0]).toHaveStyle({ top: '60%' });
});
});

describe('Hover handlers', () => {
it('invokes onMouseEnter with the HandleMouseEvent payload and emits handle:mouseenter on the bus', async () => {
const user = userEvent.setup();
const onMouseEnter = vi.fn();
const busSpy = vi.fn();

const unsubscribe = canvasEventBus.on('handle:mouseenter', busSpy);

const handles: ButtonHandleConfig[] = [
{
id: 'handle1',
type: 'source',
handleType: 'output',
showButton: true,
onAction: vi.fn(),
onMouseEnter,
},
];

render(
<ButtonHandles
handles={handles}
nodeId="test-node"
position={Position.Right}
selected={true}
visible={true}
/>
);

const button = screen.getByRole('button');
await user.hover(button);

const expectedPayload: HandleMouseEvent = {
handleId: 'handle1',
nodeId: 'test-node',
handleType: 'output',
position: Position.Right,
};
expect(onMouseEnter).toHaveBeenCalledWith(expectedPayload);
expect(busSpy).toHaveBeenCalledWith(expect.objectContaining(expectedPayload));

unsubscribe();
});

it('invokes onMouseLeave with the HandleMouseEvent payload and emits handle:mouseleave on the bus', async () => {
const user = userEvent.setup();
const onMouseLeave = vi.fn();
const busSpy = vi.fn();

const unsubscribe = canvasEventBus.on('handle:mouseleave', busSpy);

const handles: ButtonHandleConfig[] = [
{
id: 'handle1',
type: 'source',
handleType: 'output',
showButton: true,
onAction: vi.fn(),
onMouseLeave,
},
];

render(
<ButtonHandles
handles={handles}
nodeId="test-node"
position={Position.Right}
selected={true}
visible={true}
/>
);

const button = screen.getByRole('button');
await user.hover(button);
await user.unhover(button);

const expectedPayload: HandleMouseEvent = {
handleId: 'handle1',
nodeId: 'test-node',
handleType: 'output',
position: Position.Right,
};
expect(onMouseLeave).toHaveBeenCalledWith(expectedPayload);
expect(busSpy).toHaveBeenCalledWith(expect.objectContaining(expectedPayload));

unsubscribe();
});

it('emits bus events even when no onMouseEnter/onMouseLeave callbacks are wired', async () => {
const user = userEvent.setup();
const enterSpy = vi.fn();
const leaveSpy = vi.fn();

const unsubscribeEnter = canvasEventBus.on('handle:mouseenter', enterSpy);
const unsubscribeLeave = canvasEventBus.on('handle:mouseleave', leaveSpy);

const handles: ButtonHandleConfig[] = [
{
id: 'handle1',
type: 'source',
handleType: 'output',
showButton: true,
onAction: vi.fn(),
},
];

render(
<ButtonHandles
handles={handles}
nodeId="test-node"
position={Position.Right}
selected={true}
visible={true}
/>
);

const button = screen.getByRole('button');
await user.hover(button);
await user.unhover(button);

const expectedPayload = {
handleId: 'handle1',
nodeId: 'test-node',
handleType: 'output',
position: Position.Right,
};
expect(enterSpy).toHaveBeenCalledWith(expect.objectContaining(expectedPayload));
expect(leaveSpy).toHaveBeenCalledWith(expect.objectContaining(expectedPayload));

unsubscribeEnter();
unsubscribeLeave();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ export interface HandleActionEvent {
originalEvent: React.MouseEvent;
}

/** Payload passed to `onMouseEnter` / `onMouseLeave` handlers on a button handle. */
export interface HandleMouseEvent {
handleId: string;
nodeId: string;
handleType: HandleType;
position: Position;
}

type ButtonHandleProps = {
id: string;
nodeId: string;
Expand All @@ -40,6 +48,8 @@ type ButtonHandleProps = {
index?: number; // 0-based index of this handle on the edge
total?: number; // Total number of handles on this edge
onAction?: (event: HandleActionEvent) => void;
onMouseEnter?: (event: HandleMouseEvent) => void;
onMouseLeave?: (event: HandleMouseEvent) => void;
showNotches?: boolean;
customPositionAndOffsets?: HandleConfigurationSpecificPosition;
nodeWidth?: number;
Expand All @@ -63,6 +73,8 @@ const ButtonHandleBase = ({
index = 0,
total = 1,
onAction,
onMouseEnter,
onMouseLeave,
showNotches = true,
customPositionAndOffsets,
nodeWidth,
Expand All @@ -73,6 +85,33 @@ const ButtonHandleBase = ({
const [isHovered, setIsHovered] = useState(false);
const isVertical = position === Position.Top || position === Position.Bottom;

const dispatchMouseEvent = useCallback(
(
eventName: 'handle:mouseenter' | 'handle:mouseleave',
handler: ((event: HandleMouseEvent) => void) | undefined
) => {
const payload: HandleMouseEvent = {
handleId: id,
nodeId,
handleType,
position: connectionPosition,
};
handler?.(payload);
canvasEventBus.emit(eventName, payload);
},
[id, nodeId, handleType, connectionPosition]
);

const handleButtonMouseEnter = useCallback(
() => dispatchMouseEvent('handle:mouseenter', onMouseEnter),
[dispatchMouseEvent, onMouseEnter]
);

const handleButtonMouseLeave = useCallback(
() => dispatchMouseEvent('handle:mouseleave', onMouseLeave),
[dispatchMouseEvent, onMouseLeave]
);

// Calculate position along the edge for multiple handles
// Use grid-aligned positions when node dimensions are available
const positionPercent = useMemo(() => {
Expand Down Expand Up @@ -189,6 +228,8 @@ const ButtonHandleBase = ({
labelVisible={visible}
position={connectionPosition}
onAction={handleButtonClick}
onMouseEnter={handleButtonMouseEnter}
onMouseLeave={handleButtonMouseLeave}
handleRef={handleRef}
/>
) : null}
Expand Down Expand Up @@ -246,6 +287,8 @@ const ButtonHandleBase = ({
labelVisible={visible}
position={position}
onAction={handleButtonClick}
onMouseEnter={handleButtonMouseEnter}
onMouseLeave={handleButtonMouseLeave}
handleRef={handleRef}
label={label}
labelIcon={labelIcon}
Expand Down Expand Up @@ -335,6 +378,8 @@ export interface ButtonHandleConfig {
/** Runtime visibility — controls opacity (e.g. connected handles stay visible). */
showHandle?: boolean;
onAction?: (event: HandleActionEvent) => void;
onMouseEnter?: (event: HandleMouseEvent) => void;
onMouseLeave?: (event: HandleMouseEvent) => void;
customPositionAndOffsets?: HandleConfigurationSpecificPosition;
}

Expand Down Expand Up @@ -425,6 +470,8 @@ const ButtonHandlesBase = ({
visible={handleVisible}
showButton={finalSelected && handleVisible && handle.showButton}
onAction={handle.onAction}
onMouseEnter={handle.onMouseEnter}
onMouseLeave={handle.onMouseLeave}
showNotches={showNotches}
customPositionAndOffsets={customPositionAndOffsets}
nodeWidth={nodeWidth}
Expand Down
Loading
Loading