Skip to content

Commit 0507023

Browse files
feat(apollo-react): add Toolbox quickActions slot
Adds an optional `quickActions` prop to Toolbox that renders a row of icon shortcuts inside the popover chrome with a leading group and an optional trailing group separated by a vertical divider. Lets canvas consumers absorb fast-access icon strips into the existing Toolbox padding rather than stacking them above the popover where they get clipped against canvas edges. Lowers AnimatedContainer/AnimatedContent min-height from 300 to 200 so the inner list can shrink when quickActions push other content. No behavior change for existing Toolbox consumers — flex:1 still gives them more than 200 of available space at the standard 440px height. Adds ConnectorHandleIcon (BPMN-style sequence-flow arrow) used by the PO.Frontend canvas-element picker for the trailing "Connect" action.
1 parent c0a1c4f commit 0507023

8 files changed

Lines changed: 360 additions & 2 deletions

File tree

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import { render, screen, type UserEvent, userEvent } from '../../utils/testing';
3+
import type { ListItem } from './ListView';
4+
import type { ToolboxQuickAction } from './QuickActionsRow';
5+
import { Toolbox } from './Toolbox';
6+
7+
const mockItems: ListItem[] = [
8+
{
9+
id: 'item-1',
10+
name: 'Item 1',
11+
data: { value: 'data-1' },
12+
icon: { name: 'star' },
13+
},
14+
];
15+
16+
const baseProps = {
17+
title: 'Test Toolbox',
18+
initialItems: mockItems,
19+
onClose: vi.fn(),
20+
onItemSelect: vi.fn(),
21+
};
22+
23+
describe('Toolbox quickActions', () => {
24+
let user: UserEvent;
25+
26+
beforeEach(() => {
27+
user = userEvent.setup();
28+
});
29+
30+
it('does not render the quick actions row when quickActions is undefined', () => {
31+
render(<Toolbox {...baseProps} />);
32+
33+
expect(screen.queryByTestId('toolbox-quick-actions')).not.toBeInTheDocument();
34+
});
35+
36+
it('does not render the quick actions row when quickActions is empty', () => {
37+
render(<Toolbox {...baseProps} quickActions={[]} />);
38+
39+
expect(screen.queryByTestId('toolbox-quick-actions')).not.toBeInTheDocument();
40+
});
41+
42+
it('renders all leading actions and no separator when only leading actions exist', () => {
43+
const actions: ToolboxQuickAction[] = [
44+
{ id: 'a', title: 'Action A', icon: <span>A</span> },
45+
{ id: 'b', title: 'Action B', icon: <span>B</span> },
46+
{ id: 'c', title: 'Action C', icon: <span>C</span> },
47+
];
48+
49+
render(<Toolbox {...baseProps} quickActions={actions} />);
50+
51+
expect(screen.getByTestId('toolbox-quick-actions')).toBeInTheDocument();
52+
expect(screen.getByTestId('toolbox-quick-action-a')).toBeInTheDocument();
53+
expect(screen.getByTestId('toolbox-quick-action-b')).toBeInTheDocument();
54+
expect(screen.getByTestId('toolbox-quick-action-c')).toBeInTheDocument();
55+
expect(screen.queryByTestId('toolbox-quick-actions-separator')).not.toBeInTheDocument();
56+
});
57+
58+
it('renders a separator between leading and trailing actions', () => {
59+
const actions: ToolboxQuickAction[] = [
60+
{ id: 'a', title: 'Action A', icon: <span>A</span> },
61+
{ id: 'b', title: 'Action B', icon: <span>B</span> },
62+
{ id: 'connect', title: 'Connect', icon: <span>C</span>, trailing: true },
63+
];
64+
65+
render(<Toolbox {...baseProps} quickActions={actions} />);
66+
67+
expect(screen.getByTestId('toolbox-quick-actions-separator')).toBeInTheDocument();
68+
expect(screen.getByTestId('toolbox-quick-action-connect')).toBeInTheDocument();
69+
});
70+
71+
it('does not render a separator when only trailing actions exist', () => {
72+
const actions: ToolboxQuickAction[] = [
73+
{ id: 'connect', title: 'Connect', icon: <span>C</span>, trailing: true },
74+
];
75+
76+
render(<Toolbox {...baseProps} quickActions={actions} />);
77+
78+
expect(screen.getByTestId('toolbox-quick-action-connect')).toBeInTheDocument();
79+
expect(screen.queryByTestId('toolbox-quick-actions-separator')).not.toBeInTheDocument();
80+
});
81+
82+
it('fires onClick when an action button is clicked', async () => {
83+
const onClick = vi.fn();
84+
const actions: ToolboxQuickAction[] = [
85+
{ id: 'a', title: 'Action A', icon: <span>A</span>, onClick },
86+
];
87+
88+
render(<Toolbox {...baseProps} quickActions={actions} />);
89+
90+
await user.click(screen.getByTestId('toolbox-quick-action-a'));
91+
expect(onClick).toHaveBeenCalledTimes(1);
92+
});
93+
94+
it('fires onMouseEnter and onMouseLeave on hover', async () => {
95+
const onMouseEnter = vi.fn();
96+
const onMouseLeave = vi.fn();
97+
const actions: ToolboxQuickAction[] = [
98+
{ id: 'a', title: 'Action A', icon: <span>A</span>, onMouseEnter, onMouseLeave },
99+
];
100+
101+
render(<Toolbox {...baseProps} quickActions={actions} />);
102+
103+
const button = screen.getByTestId('toolbox-quick-action-a');
104+
await user.hover(button);
105+
expect(onMouseEnter).toHaveBeenCalled();
106+
107+
await user.unhover(button);
108+
expect(onMouseLeave).toHaveBeenCalled();
109+
});
110+
111+
it('disables the button and suppresses onClick when disabled is true', async () => {
112+
const onClick = vi.fn();
113+
const actions: ToolboxQuickAction[] = [
114+
{ id: 'a', title: 'Action A', icon: <span>A</span>, onClick, disabled: true },
115+
];
116+
117+
render(<Toolbox {...baseProps} quickActions={actions} />);
118+
119+
const button = screen.getByTestId('toolbox-quick-action-a');
120+
expect(button).toBeDisabled();
121+
122+
await user.click(button);
123+
expect(onClick).not.toHaveBeenCalled();
124+
});
125+
126+
it('uses title as aria-label on each button', () => {
127+
const actions: ToolboxQuickAction[] = [
128+
{ id: 'a', title: 'Add element', icon: <span>A</span> },
129+
{ id: 'b', title: 'Delete element', icon: <span>B</span> },
130+
];
131+
132+
render(<Toolbox {...baseProps} quickActions={actions} />);
133+
134+
expect(screen.getByRole('button', { name: 'Add element' })).toBeInTheDocument();
135+
expect(screen.getByRole('button', { name: 'Delete element' })).toBeInTheDocument();
136+
});
137+
138+
it('renders the quick actions row above the title', () => {
139+
const actions: ToolboxQuickAction[] = [{ id: 'a', title: 'Action A', icon: <span>A</span> }];
140+
141+
render(<Toolbox {...baseProps} quickActions={actions} />);
142+
143+
const row = screen.getByTestId('toolbox-quick-actions');
144+
const title = screen.getByText('Test Toolbox');
145+
146+
expect(row.compareDocumentPosition(title) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
147+
});
148+
});
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import styled from '@emotion/styled';
2+
import { Button } from '@uipath/apollo-wind';
3+
import { memo, type MouseEvent, type ReactNode } from 'react';
4+
import { TOOLBOX_PADDING_X } from '../../constants';
5+
import { CanvasTooltip } from '../CanvasTooltip';
6+
7+
export type ToolboxQuickAction = {
8+
id: string;
9+
title: string;
10+
icon: ReactNode;
11+
onClick?: (e: MouseEvent<HTMLButtonElement>) => void;
12+
onMouseEnter?: () => void;
13+
onMouseLeave?: () => void;
14+
trailing?: boolean;
15+
disabled?: boolean;
16+
};
17+
18+
interface QuickActionsRowProps {
19+
actions: ToolboxQuickAction[];
20+
}
21+
22+
const Container = styled.div`
23+
display: flex;
24+
align-items: center;
25+
justify-content: center;
26+
gap: 12px;
27+
min-height: 44px;
28+
padding: 0 ${TOOLBOX_PADDING_X}px 8px;
29+
margin: 0 -${TOOLBOX_PADDING_X}px;
30+
border-bottom: 1px solid var(--canvas-border-de-emp);
31+
flex-shrink: 0;
32+
`;
33+
34+
const Separator = styled.div`
35+
width: 1px;
36+
align-self: stretch;
37+
margin: 0 4px;
38+
background: var(--canvas-border-de-emp);
39+
flex-shrink: 0;
40+
`;
41+
42+
function QuickActionButton({ action, wide }: { action: ToolboxQuickAction; wide?: boolean }) {
43+
return (
44+
<CanvasTooltip content={action.title}>
45+
<Button
46+
type="button"
47+
variant="ghost"
48+
size="icon"
49+
className={wide ? 'h-9 w-14 aspect-auto [&_svg]:size-6' : 'h-9 w-9 [&_svg]:size-6'}
50+
aria-label={action.title}
51+
disabled={action.disabled}
52+
onClick={action.onClick}
53+
onMouseEnter={action.onMouseEnter}
54+
onMouseLeave={action.onMouseLeave}
55+
data-testid={`toolbox-quick-action-${action.id}`}
56+
>
57+
{action.icon}
58+
</Button>
59+
</CanvasTooltip>
60+
);
61+
}
62+
63+
export const QuickActionsRow = memo(function QuickActionsRow({ actions }: QuickActionsRowProps) {
64+
if (!actions.length) return null;
65+
66+
const leading = actions.filter((a) => !a.trailing);
67+
const trailing = actions.filter((a) => a.trailing);
68+
69+
return (
70+
<Container data-testid="toolbox-quick-actions">
71+
{leading.map((action) => (
72+
<QuickActionButton key={action.id} action={action} />
73+
))}
74+
{trailing.length > 0 && leading.length > 0 && (
75+
<Separator data-testid="toolbox-quick-actions-separator" />
76+
)}
77+
{trailing.map((action) => (
78+
<QuickActionButton key={action.id} action={action} wide />
79+
))}
80+
</Container>
81+
);
82+
});
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import type { Meta, StoryObj } from '@storybook/react-vite';
2+
import {
3+
BpmnBoundaryEventDefault,
4+
BpmnEndEventDefault,
5+
BpmnGatewayExclusive,
6+
FigureRectangle54,
7+
} from '../../../icons';
8+
import { ConnectorHandleIcon } from '../../icons';
9+
import { withCanvasProviders } from '../../storybook-utils';
10+
import type { ListItem } from './ListView';
11+
import type { ToolboxQuickAction } from './QuickActionsRow';
12+
import { Toolbox } from './Toolbox';
13+
14+
const meta: Meta<typeof Toolbox> = {
15+
title: 'Canvas/Toolbox',
16+
component: Toolbox,
17+
parameters: {
18+
layout: 'centered',
19+
},
20+
decorators: [withCanvasProviders()],
21+
};
22+
23+
export default meta;
24+
type Story = StoryObj<typeof Toolbox>;
25+
26+
const SAMPLE_ITEMS: ListItem[] = [
27+
{ id: 'task', name: 'Task', icon: { name: 'square' }, data: {} },
28+
{ id: 'call-activity', name: 'Call Activity', icon: { name: 'box' }, data: {} },
29+
{ id: 'gateway', name: 'Gateway', icon: { name: 'diamond' }, data: {} },
30+
{ id: 'event', name: 'Event', icon: { name: 'circle' }, data: {} },
31+
{ id: 'subprocess', name: 'Subprocess', icon: { name: 'layers' }, data: {} },
32+
];
33+
34+
export const Default: Story = {
35+
args: {
36+
title: 'Add element',
37+
initialItems: SAMPLE_ITEMS,
38+
onClose: () => {},
39+
onItemSelect: () => {},
40+
},
41+
};
42+
43+
/**
44+
* Pairs a row of icon shortcuts with the picker so high-frequency shapes are
45+
* one click away. Trailing actions appear after a separator — used in the
46+
* BPMN canvas-element-picker to surface the "Connect" tool alongside the
47+
* shape buttons. Other canvas teams adding fast-access tools above a Toolbox
48+
* picker can pass them via `quickActions` instead of stacking another row
49+
* outside the popover.
50+
*/
51+
export const WithQuickActions: Story = {
52+
args: {
53+
title: 'Add element',
54+
initialItems: SAMPLE_ITEMS,
55+
onClose: () => {},
56+
onItemSelect: () => {},
57+
quickActions: [
58+
{
59+
id: 'task',
60+
title: 'Task',
61+
icon: <FigureRectangle54 size={20} />,
62+
onClick: () => {},
63+
},
64+
{
65+
id: 'gateway',
66+
title: 'Exclusive gateway',
67+
icon: <BpmnGatewayExclusive size={20} />,
68+
onClick: () => {},
69+
},
70+
{
71+
id: 'intermediate-event',
72+
title: 'Intermediate event',
73+
icon: <BpmnBoundaryEventDefault size={20} />,
74+
onClick: () => {},
75+
},
76+
{
77+
id: 'end-event',
78+
title: 'End event',
79+
icon: <BpmnEndEventDefault size={20} />,
80+
onClick: () => {},
81+
},
82+
{
83+
id: 'connect',
84+
title: 'Connect',
85+
icon: <ConnectorHandleIcon w={20} h={20} />,
86+
onClick: () => {},
87+
trailing: true,
88+
},
89+
] satisfies ToolboxQuickAction[],
90+
},
91+
};

packages/apollo-react/src/canvas/components/Toolbox/Toolbox.styles.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,15 @@ export const AnimatedContainer = styled.div`
3333
display: flex;
3434
flex-direction: column;
3535
overflow: hidden;
36-
min-height: 300px;
36+
min-height: 200px;
3737
`;
3838

3939
export const AnimatedContent = styled.div<{ entering?: boolean; direction?: 'forward' | 'back' }>`
4040
flex: 1;
4141
display: flex;
4242
flex-direction: column;
4343
animation: ${(props) => (props.entering ? `slideIn-${props.direction}` : 'none')} 0.15s ease-out;
44-
min-height: 300px;
44+
min-height: 200px;
4545
4646
@keyframes slideIn-forward {
4747
from {

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
} from '../../constants';
1212
import { Header } from './Header';
1313
import { type ListItem, ListView, type ListViewHandle, type RenderItem } from './ListView';
14+
import { QuickActionsRow, type ToolboxQuickAction } from './QuickActionsRow';
1415
import { SearchBox } from './SearchBox';
1516
import { AnimatedContainer, AnimatedContent } from './Toolbox.styles';
1617

@@ -67,6 +68,13 @@ export interface ToolboxProps<T> {
6768
onItemHover?: (item: ListItem<T>) => void;
6869
onBack?: () => void;
6970
onSearch?: ToolboxSearchHandler<T>;
71+
/**
72+
* Optional row of icon shortcuts rendered above the title. Apollo controls
73+
* the visuals so the strip stays consistent across consumers; pass the
74+
* leading actions plain and set `trailing: true` on actions that should
75+
* appear after the visual separator.
76+
*/
77+
quickActions?: ToolboxQuickAction[];
7078
}
7179

7280
function getNextSelectableIndex(
@@ -126,6 +134,7 @@ export function Toolbox<T>({
126134
loading,
127135
fullWidth = false,
128136
fullHeight = false,
137+
quickActions,
129138
}: ToolboxProps<T>) {
130139
const [items, setItems] = useState<ListItem<T>[]>(initialItems);
131140
const [search, setSearch] = useState('');
@@ -596,6 +605,7 @@ export function Toolbox<T>({
596605
w={fullWidth ? '100%' : TOOLBOX_WIDTH}
597606
h={fullHeight ? '100%' : TOOLBOX_HEIGHT}
598607
>
608+
{quickActions && quickActions.length > 0 && <QuickActionsRow actions={quickActions} />}
599609
<Header
600610
title={currentParentItem?.name || title}
601611
onBack={handleBackTransition}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export type { ListItem } from './ListView';
2+
export type { ToolboxQuickAction } from './QuickActionsRow';
23
export * from './Toolbox';

0 commit comments

Comments
 (0)