Skip to content

Commit 3e3a600

Browse files
claudekittyyueli
authored andcommitted
feat(apollo-react): expose caseFlowManifest + handle hover handlers
Surface manifest pieces needed by PO.Frontend's case-management canvas to register `caseFlowManifest` (PR #720), wrap apollo's `BaseNode` in a thin host-side `CaseBaseNode` renderer, provide the canvas mode that `BaseNode` reads via `useBaseCanvasMode()`, and expose hover events on the source-handle `+` add-button so hosts can mount a hover-preview affordance. - Add `CaseFlow/index.ts` re-exporting the manifest pieces, and surface it via `components/index.ts`. - Add `toolbarExtensions.design` (change-trigger-type) to the trigger manifest, mirroring flow-workbench's `triggerToolbarExtensions`. - Mark the trigger's source handle as `showButton: true` so `BaseNode` draws the inline add-next-stage `+` button (host wires the click via `onHandleAction`). - Re-export `BaseCanvasModeProvider` from the BaseCanvas barrel so hosts that don't use apollo's `BaseCanvas` (PO.Frontend has its own) can still satisfy `BaseNode`'s mode dependency. - Plumb optional `onMouseEnter` / `onMouseLeave` callbacks through `HandleButton` → `ButtonHandle` → `ButtonHandlesBase` → `useButtonHandles` → `BaseNodeOverrideConfig.onHandleMouseEnter` / `onHandleMouseLeave`, with parallel `handle:mouseenter` / `handle:mouseleave` events on `canvasEventBus`. Hosts (e.g. PO.Frontend) use these to preview the to-be-created node when the cursor enters the `+`, then commit on `onHandleAction`.
1 parent 37b35ee commit 3e3a600

16 files changed

Lines changed: 698 additions & 8 deletions

packages/apollo-react/src/canvas/components/BaseCanvas/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export { BaseCanvas } from './BaseCanvas';
22
export * from './BaseCanvas.constants';
33
export * from './BaseCanvas.hooks';
44
export * from './BaseCanvas.types';
5+
export * from './BaseCanvasModeProvider';
56
export * from './CanvasBackground';
67
export * from './CanvasProviders';
78
export * from './CanvasThemeContext';

packages/apollo-react/src/canvas/components/BaseNode/BaseNode.test.tsx

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { render, screen } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
23
import type { Node, NodeProps } from '@uipath/apollo-react/canvas/xyflow/react';
34
import { Position } from '@uipath/apollo-react/canvas/xyflow/react';
45
import { describe, expect, it, vi } from 'vitest';
@@ -10,7 +11,13 @@ const DEFAULT_MANIFEST = {
1011
} as const;
1112

1213
// Hoisted mocks — available inside vi.mock factories
13-
const { mockUpdateNode, mockHandleConfigs, mockManifest } = vi.hoisted(() => ({
14+
const {
15+
mockUpdateNode,
16+
mockHandleConfigs,
17+
mockManifest,
18+
mockUseButtonHandles,
19+
mockOverrideConfig,
20+
} = vi.hoisted(() => ({
1421
mockUpdateNode: vi.fn(),
1522
mockHandleConfigs: { current: undefined as HandleGroupManifest[] | undefined },
1623
mockManifest: {
@@ -19,6 +26,9 @@ const { mockUpdateNode, mockHandleConfigs, mockManifest } = vi.hoisted(() => ({
1926
handleConfiguration: [],
2027
} as Record<string, unknown>,
2128
},
29+
// biome-ignore lint/suspicious/noExplicitAny: hook receives a broad typed options object
30+
mockUseButtonHandles: vi.fn() as any,
31+
mockOverrideConfig: { current: {} as Record<string, unknown> },
2232
}));
2333

2434
// xyflow is globally mocked in canvas-mocks.ts; extend with test-specific overrides.
@@ -50,13 +60,21 @@ vi.mock('../BaseCanvas/SelectionStateContext', () => ({
5060
vi.mock('../BaseCanvas/CanvasThemeContext', () => ({
5161
useCanvasTheme: () => ({ isDarkMode: false }),
5262
}));
53-
vi.mock('../ButtonHandle/useButtonHandles', () => ({ useButtonHandles: () => null }));
63+
vi.mock('../ButtonHandle/useButtonHandles', () => ({
64+
useButtonHandles: (opts: unknown) => {
65+
mockUseButtonHandles(opts);
66+
return null;
67+
},
68+
}));
5469
vi.mock('../ButtonHandle/SmartHandle', () => ({
5570
SmartHandle: () => null,
5671
SmartHandleProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
5772
}));
5873
vi.mock('./BaseNodeConfigContext', () => ({
59-
useBaseNodeOverrideConfig: () => ({ handleConfigurations: mockHandleConfigs.current }),
74+
useBaseNodeOverrideConfig: () => ({
75+
handleConfigurations: mockHandleConfigs.current,
76+
...mockOverrideConfig.current,
77+
}),
6078
}));
6179
vi.mock('../../utils/adornment-resolver', () => ({ resolveAdornments: () => ({}) }));
6280
vi.mock('../../utils/toolbar-resolver', () => ({ resolveToolbar: () => undefined }));
@@ -122,8 +140,10 @@ const makeHandles = (position: Position, count: number): HandleGroupManifest[] =
122140
describe('BaseNode', () => {
123141
afterEach(() => {
124142
mockUpdateNode.mockClear();
143+
mockUseButtonHandles.mockClear();
125144
mockHandleConfigs.current = undefined;
126145
mockManifest.current = { ...DEFAULT_MANIFEST };
146+
mockOverrideConfig.current = {};
127147
});
128148

129149
describe('Height computation', () => {
@@ -283,4 +303,38 @@ describe('BaseNode', () => {
283303
expect(screen.queryByText('M', { selector: '[aria-hidden="true"]' })).not.toBeInTheDocument();
284304
});
285305
});
306+
307+
// BaseNode forwards the consumer-facing `onHandleMouseEnter`/`onHandleMouseLeave`
308+
// from `BaseNodeOverrideConfigProvider` into `useButtonHandles` as
309+
// `handleMouseEnter`/`handleMouseLeave`. Trigger those by reaching into the
310+
// captured hook arguments and invoking them with a synthetic payload.
311+
describe('Handle hover handlers', () => {
312+
it('forwards onHandleMouseEnter/onHandleMouseLeave overrides and fires with the payload', () => {
313+
const onHandleMouseEnter = vi.fn();
314+
const onHandleMouseLeave = vi.fn();
315+
mockOverrideConfig.current = { onHandleMouseEnter, onHandleMouseLeave };
316+
317+
render(<BaseNode {...defaultProps} />);
318+
319+
expect(mockUseButtonHandles).toHaveBeenCalled();
320+
const opts = mockUseButtonHandles.mock.calls.at(-1)?.[0] as {
321+
handleMouseEnter?: (e: unknown) => void;
322+
handleMouseLeave?: (e: unknown) => void;
323+
};
324+
expect(opts.handleMouseEnter).toBe(onHandleMouseEnter);
325+
expect(opts.handleMouseLeave).toBe(onHandleMouseLeave);
326+
327+
const payload = {
328+
handleId: 'output',
329+
nodeId: 'test-node',
330+
handleType: 'output',
331+
position: Position.Right,
332+
};
333+
opts.handleMouseEnter?.(payload);
334+
opts.handleMouseLeave?.(payload);
335+
336+
expect(onHandleMouseEnter).toHaveBeenCalledWith(payload);
337+
expect(onHandleMouseLeave).toHaveBeenCalledWith(payload);
338+
});
339+
});
286340
});

packages/apollo-react/src/canvas/components/BaseNode/BaseNode.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ const BaseNodeComponent = (props: NodeProps<Node<BaseNodeData>>) => {
8787
// Read runtime configuration from context (provided by parent node components)
8888
const {
8989
onHandleAction: onHandleActionProp,
90+
onHandleMouseEnter: onHandleMouseEnterProp,
91+
onHandleMouseLeave: onHandleMouseLeaveProp,
9092
shouldShowAddButtonFn: shouldShowAddButtonFnProp,
9193
shouldShowButtonHandleNotchesFn: shouldShowButtonHandleNotchesFnProp,
9294
toolbarConfig: toolbarConfigProp,
@@ -480,6 +482,8 @@ const BaseNodeComponent = (props: NodeProps<Node<BaseNodeData>>) => {
480482
handleConfigurations,
481483
shouldShowHandles,
482484
handleAction,
485+
handleMouseEnter: onHandleMouseEnterProp,
486+
handleMouseLeave: onHandleMouseLeaveProp,
483487
nodeId: id,
484488
selected: selected ?? false,
485489
hovered: isHovered,

packages/apollo-react/src/canvas/components/BaseNode/BaseNodeConfigContext.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { createContext, useContext } from 'react';
22
import type { HandleGroupManifest } from '../../schema/node-definition';
33
import type { ElementStatusValues } from '../../types/execution';
4-
import type { HandleActionEvent } from '../ButtonHandle/ButtonHandle';
4+
import type { HandleActionEvent, HandleMouseEvent } from '../ButtonHandle/ButtonHandle';
55
import type { NodeToolbarConfig } from '../Toolbar';
66
import type { FooterVariant, NodeAdornments } from './BaseNode.types';
77

@@ -20,6 +20,10 @@ import type { FooterVariant, NodeAdornments } from './BaseNode.types';
2020
export interface BaseNodeOverrideConfig {
2121
// Callbacks (Runtime Behavior)
2222
onHandleAction?: (event: HandleActionEvent) => void;
23+
/** Fired when the cursor enters a source handle's inline add button. */
24+
onHandleMouseEnter?: (event: HandleMouseEvent) => void;
25+
/** Fired when the cursor leaves a source handle's inline add button. */
26+
onHandleMouseLeave?: (event: HandleMouseEvent) => void;
2327
shouldShowAddButtonFn?: (opts: { showAddButton: boolean; selected: boolean }) => boolean;
2428
shouldShowButtonHandleNotchesFn?: (opts: {
2529
isConnecting: boolean;

packages/apollo-react/src/canvas/components/ButtonHandle/ButtonHandle.test.tsx

Lines changed: 136 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import { render, screen } from '@testing-library/react';
22
import userEvent from '@testing-library/user-event';
33
import { Position } from '@uipath/apollo-react/canvas/xyflow/react';
44
import { describe, expect, it, vi } from 'vitest';
5-
import type { ButtonHandleConfig } from './ButtonHandle';
5+
import { canvasEventBus } from '../../utils/CanvasEventBus';
6+
import type { ButtonHandleConfig, HandleMouseEvent } from './ButtonHandle';
67
import { ButtonHandles } from './ButtonHandle';
78

89
// Mock @xyflow/react Handle component
@@ -380,4 +381,138 @@ describe('ButtonHandles', () => {
380381
expect(handleElements[0]).toHaveStyle({ top: '60%' });
381382
});
382383
});
384+
385+
describe('Hover handlers', () => {
386+
it('invokes onMouseEnter with the HandleMouseEvent payload and emits handle:mouseenter on the bus', async () => {
387+
const user = userEvent.setup();
388+
const onMouseEnter = vi.fn();
389+
const busSpy = vi.fn();
390+
391+
const unsubscribe = canvasEventBus.on('handle:mouseenter', busSpy);
392+
393+
const handles: ButtonHandleConfig[] = [
394+
{
395+
id: 'handle1',
396+
type: 'source',
397+
handleType: 'output',
398+
showButton: true,
399+
onAction: vi.fn(),
400+
onMouseEnter,
401+
},
402+
];
403+
404+
render(
405+
<ButtonHandles
406+
handles={handles}
407+
nodeId="test-node"
408+
position={Position.Right}
409+
selected={true}
410+
visible={true}
411+
/>
412+
);
413+
414+
const button = screen.getByRole('button');
415+
await user.hover(button);
416+
417+
const expectedPayload: HandleMouseEvent = {
418+
handleId: 'handle1',
419+
nodeId: 'test-node',
420+
handleType: 'output',
421+
position: Position.Right,
422+
};
423+
expect(onMouseEnter).toHaveBeenCalledWith(expectedPayload);
424+
expect(busSpy).toHaveBeenCalledWith(expect.objectContaining(expectedPayload));
425+
426+
unsubscribe();
427+
});
428+
429+
it('invokes onMouseLeave with the HandleMouseEvent payload and emits handle:mouseleave on the bus', async () => {
430+
const user = userEvent.setup();
431+
const onMouseLeave = vi.fn();
432+
const busSpy = vi.fn();
433+
434+
const unsubscribe = canvasEventBus.on('handle:mouseleave', busSpy);
435+
436+
const handles: ButtonHandleConfig[] = [
437+
{
438+
id: 'handle1',
439+
type: 'source',
440+
handleType: 'output',
441+
showButton: true,
442+
onAction: vi.fn(),
443+
onMouseLeave,
444+
},
445+
];
446+
447+
render(
448+
<ButtonHandles
449+
handles={handles}
450+
nodeId="test-node"
451+
position={Position.Right}
452+
selected={true}
453+
visible={true}
454+
/>
455+
);
456+
457+
const button = screen.getByRole('button');
458+
await user.hover(button);
459+
await user.unhover(button);
460+
461+
const expectedPayload: HandleMouseEvent = {
462+
handleId: 'handle1',
463+
nodeId: 'test-node',
464+
handleType: 'output',
465+
position: Position.Right,
466+
};
467+
expect(onMouseLeave).toHaveBeenCalledWith(expectedPayload);
468+
expect(busSpy).toHaveBeenCalledWith(expect.objectContaining(expectedPayload));
469+
470+
unsubscribe();
471+
});
472+
473+
it('emits bus events even when no onMouseEnter/onMouseLeave callbacks are wired', async () => {
474+
const user = userEvent.setup();
475+
const enterSpy = vi.fn();
476+
const leaveSpy = vi.fn();
477+
478+
const unsubscribeEnter = canvasEventBus.on('handle:mouseenter', enterSpy);
479+
const unsubscribeLeave = canvasEventBus.on('handle:mouseleave', leaveSpy);
480+
481+
const handles: ButtonHandleConfig[] = [
482+
{
483+
id: 'handle1',
484+
type: 'source',
485+
handleType: 'output',
486+
showButton: true,
487+
onAction: vi.fn(),
488+
},
489+
];
490+
491+
render(
492+
<ButtonHandles
493+
handles={handles}
494+
nodeId="test-node"
495+
position={Position.Right}
496+
selected={true}
497+
visible={true}
498+
/>
499+
);
500+
501+
const button = screen.getByRole('button');
502+
await user.hover(button);
503+
await user.unhover(button);
504+
505+
const expectedPayload = {
506+
handleId: 'handle1',
507+
nodeId: 'test-node',
508+
handleType: 'output',
509+
position: Position.Right,
510+
};
511+
expect(enterSpy).toHaveBeenCalledWith(expect.objectContaining(expectedPayload));
512+
expect(leaveSpy).toHaveBeenCalledWith(expect.objectContaining(expectedPayload));
513+
514+
unsubscribeEnter();
515+
unsubscribeLeave();
516+
});
517+
});
383518
});

packages/apollo-react/src/canvas/components/ButtonHandle/ButtonHandle.tsx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@ export interface HandleActionEvent {
2222
originalEvent: React.MouseEvent;
2323
}
2424

25+
/** Payload passed to `onMouseEnter` / `onMouseLeave` handlers on a button handle. */
26+
export interface HandleMouseEvent {
27+
handleId: string;
28+
nodeId: string;
29+
handleType: HandleType;
30+
position: Position;
31+
}
32+
2533
type ButtonHandleProps = {
2634
id: string;
2735
nodeId: string;
@@ -40,6 +48,8 @@ type ButtonHandleProps = {
4048
index?: number; // 0-based index of this handle on the edge
4149
total?: number; // Total number of handles on this edge
4250
onAction?: (event: HandleActionEvent) => void;
51+
onMouseEnter?: (event: HandleMouseEvent) => void;
52+
onMouseLeave?: (event: HandleMouseEvent) => void;
4353
showNotches?: boolean;
4454
customPositionAndOffsets?: HandleConfigurationSpecificPosition;
4555
nodeWidth?: number;
@@ -63,6 +73,8 @@ const ButtonHandleBase = ({
6373
index = 0,
6474
total = 1,
6575
onAction,
76+
onMouseEnter,
77+
onMouseLeave,
6678
showNotches = true,
6779
customPositionAndOffsets,
6880
nodeWidth,
@@ -73,6 +85,26 @@ const ButtonHandleBase = ({
7385
const [isHovered, setIsHovered] = useState(false);
7486
const isVertical = position === Position.Top || position === Position.Bottom;
7587

88+
const handleButtonMouseEnter = useCallback(() => {
89+
onMouseEnter?.({ handleId: id, nodeId, handleType, position: connectionPosition });
90+
canvasEventBus.emit('handle:mouseenter', {
91+
handleId: id,
92+
nodeId,
93+
handleType,
94+
position: connectionPosition,
95+
});
96+
}, [id, nodeId, handleType, connectionPosition, onMouseEnter]);
97+
98+
const handleButtonMouseLeave = useCallback(() => {
99+
onMouseLeave?.({ handleId: id, nodeId, handleType, position: connectionPosition });
100+
canvasEventBus.emit('handle:mouseleave', {
101+
handleId: id,
102+
nodeId,
103+
handleType,
104+
position: connectionPosition,
105+
});
106+
}, [id, nodeId, handleType, connectionPosition, onMouseLeave]);
107+
76108
// Calculate position along the edge for multiple handles
77109
// Use grid-aligned positions when node dimensions are available
78110
const positionPercent = useMemo(() => {
@@ -189,6 +221,8 @@ const ButtonHandleBase = ({
189221
labelVisible={visible}
190222
position={connectionPosition}
191223
onAction={handleButtonClick}
224+
onMouseEnter={handleButtonMouseEnter}
225+
onMouseLeave={handleButtonMouseLeave}
192226
handleRef={handleRef}
193227
/>
194228
) : null}
@@ -246,6 +280,8 @@ const ButtonHandleBase = ({
246280
labelVisible={visible}
247281
position={position}
248282
onAction={handleButtonClick}
283+
onMouseEnter={handleButtonMouseEnter}
284+
onMouseLeave={handleButtonMouseLeave}
249285
handleRef={handleRef}
250286
label={label}
251287
labelIcon={labelIcon}
@@ -335,6 +371,8 @@ export interface ButtonHandleConfig {
335371
/** Runtime visibility — controls opacity (e.g. connected handles stay visible). */
336372
showHandle?: boolean;
337373
onAction?: (event: HandleActionEvent) => void;
374+
onMouseEnter?: (event: HandleMouseEvent) => void;
375+
onMouseLeave?: (event: HandleMouseEvent) => void;
338376
customPositionAndOffsets?: HandleConfigurationSpecificPosition;
339377
}
340378

@@ -425,6 +463,8 @@ const ButtonHandlesBase = ({
425463
visible={handleVisible}
426464
showButton={finalSelected && handleVisible && handle.showButton}
427465
onAction={handle.onAction}
466+
onMouseEnter={handle.onMouseEnter}
467+
onMouseLeave={handle.onMouseLeave}
428468
showNotches={showNotches}
429469
customPositionAndOffsets={customPositionAndOffsets}
430470
nodeWidth={nodeWidth}

0 commit comments

Comments
 (0)