Skip to content

Commit aa9f0cf

Browse files
committed
fix: switch to item-based organization
1 parent 971b0fd commit aa9f0cf

File tree

13 files changed

+381
-155
lines changed

13 files changed

+381
-155
lines changed
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import React from 'react';
2+
import { render, screen, userEvent } from '@mongodb-js/testing-library-compass';
3+
import { expect } from 'chai';
4+
import sinon from 'sinon';
5+
import { ContextMenuProvider } from '@mongodb-js/compass-context-menu';
6+
import { useContextMenuItems, ContextMenu } from './context-menu';
7+
import type { ContextMenuItem } from '@mongodb-js/compass-context-menu';
8+
9+
describe('useContextMenuItems', function () {
10+
const TestComponent = ({ items }: { items: ContextMenuItem[] }) => {
11+
const ref = useContextMenuItems(items);
12+
13+
return (
14+
<div data-testid="test-trigger" ref={ref}>
15+
Test Component
16+
</div>
17+
);
18+
};
19+
20+
describe('when used outside provider', function () {
21+
it('throws an error', function () {
22+
const items = [
23+
{
24+
label: 'Test Item',
25+
onAction: () => {},
26+
},
27+
];
28+
29+
expect(() => {
30+
render(<TestComponent items={items} />);
31+
}).to.throw('useContextMenu called outside of the provider');
32+
});
33+
});
34+
35+
describe('with a valid provider', function () {
36+
beforeEach(() => {
37+
// Create the container for the context menu portal
38+
const container = document.createElement('div');
39+
container.id = 'context-menu-container';
40+
document.body.appendChild(container);
41+
});
42+
43+
afterEach(() => {
44+
// Clean up the container
45+
const container = document.getElementById('context-menu-container');
46+
if (container) {
47+
document.body.removeChild(container);
48+
}
49+
});
50+
51+
it('renders without error', function () {
52+
const items = [
53+
{
54+
label: 'Test Item',
55+
onAction: () => {},
56+
},
57+
];
58+
59+
render(
60+
<ContextMenuProvider wrapper={ContextMenu}>
61+
<TestComponent items={items} />
62+
</ContextMenuProvider>
63+
);
64+
65+
expect(screen.getByTestId('test-trigger')).to.exist;
66+
});
67+
68+
it('shows context menu with items on right click', function () {
69+
const items = [
70+
{
71+
label: 'Test Item 1',
72+
onAction: () => {},
73+
},
74+
{
75+
label: 'Test Item 2',
76+
onAction: () => {},
77+
},
78+
];
79+
80+
render(
81+
<ContextMenuProvider wrapper={ContextMenu}>
82+
<TestComponent items={items} />
83+
</ContextMenuProvider>
84+
);
85+
86+
const trigger = screen.getByTestId('test-trigger');
87+
userEvent.click(trigger, { button: 2 });
88+
89+
// The menu items should be rendered
90+
expect(screen.getByTestId('context-menu-item-Test Item 1')).to.exist;
91+
expect(screen.getByTestId('context-menu-item-Test Item 2')).to.exist;
92+
});
93+
94+
it('triggers the correct action when menu item is clicked', function () {
95+
const onAction = sinon.spy();
96+
const items = [
97+
{
98+
label: 'Test Item 1',
99+
onAction: () => onAction(1),
100+
},
101+
{
102+
label: 'Test Item 2',
103+
onAction: () => onAction(2),
104+
},
105+
];
106+
107+
render(
108+
<ContextMenuProvider wrapper={ContextMenu}>
109+
<TestComponent items={items} />
110+
</ContextMenuProvider>
111+
);
112+
113+
const trigger = screen.getByTestId('test-trigger');
114+
userEvent.click(trigger, { button: 2 });
115+
116+
const menuItem = screen.getByTestId('context-menu-item-Test Item 2');
117+
userEvent.click(menuItem);
118+
119+
expect(onAction).to.have.been.calledOnceWithExactly(2);
120+
});
121+
122+
it('renders menu items with separators', function () {
123+
const items = [
124+
{
125+
label: 'Test Item 1',
126+
onAction: () => {},
127+
},
128+
{
129+
label: 'Test Item 2',
130+
onAction: () => {},
131+
},
132+
];
133+
134+
render(
135+
<ContextMenuProvider wrapper={ContextMenu}>
136+
<TestComponent items={items} />
137+
</ContextMenuProvider>
138+
);
139+
140+
const trigger = screen.getByTestId('test-trigger');
141+
userEvent.click(trigger, { button: 2 });
142+
143+
// Should find both menu items and a separator between them
144+
expect(screen.getByTestId('context-menu-item-Test Item 1')).to.exist;
145+
expect(screen.getByRole('separator')).to.exist;
146+
expect(screen.getByTestId('context-menu-item-Test Item 2')).to.exist;
147+
});
148+
});
149+
});
Lines changed: 38 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,53 @@
11
import React from 'react';
2-
import { css, cx } from '@leafygreen-ui/emotion';
32
import { Menu, MenuItem, MenuSeparator } from './leafygreen';
43
import type { ContextMenuItem } from '@mongodb-js/compass-context-menu';
54
import { useContextMenu } from '@mongodb-js/compass-context-menu';
5+
import { ContextMenuProvider as ContextMenuProviderBase } from '@mongodb-js/compass-context-menu';
6+
import type { ContextMenuItemGroup } from '@mongodb-js/compass-context-menu/dist/types';
67

7-
const menuStyle = css({
8-
position: 'fixed',
9-
zIndex: 9999,
10-
});
8+
export function ContextMenuProvider({
9+
children,
10+
}: {
11+
children: React.ReactNode;
12+
}) {
13+
return (
14+
<ContextMenuProviderBase wrapper={ContextMenu}>
15+
{children}
16+
</ContextMenuProviderBase>
17+
);
18+
}
1119

1220
export type ContextMenuProps = {
13-
items: ContextMenuItem[];
21+
itemGroups: ContextMenuItemGroup[];
1422
className?: string;
1523
'data-testid'?: string;
1624
};
1725

18-
export function ContextMenu({
19-
items,
20-
className,
21-
'data-testid': dataTestId,
22-
}: ContextMenuProps) {
26+
export function ContextMenu({ itemGroups }: ContextMenuProps) {
2327
return (
24-
<Menu className={cx(menuStyle, className)} data-testid={dataTestId}>
25-
{items.map((item, idx) => {
26-
const { label, onAction } = item;
27-
const isLastItem = idx === items.length - 1;
28-
28+
<Menu open={true} renderMode="inline">
29+
{itemGroups.map((itemGroup: ContextMenuItemGroup, groupIndex: number) => {
2930
return (
30-
<>
31-
{!isLastItem && <MenuSeparator />}
32-
<MenuItem
33-
key={`${label}-${idx}`}
34-
data-testid={`context-menu-item-${label}`}
35-
onClick={(evt: React.MouseEvent) => {
36-
evt.stopPropagation();
37-
onAction?.(evt);
38-
}}
39-
>
40-
{label}
41-
</MenuItem>
42-
</>
31+
<div key={`menu-group-${groupIndex}`}>
32+
{itemGroup.items.map((item: ContextMenuItem, itemIndex: number) => {
33+
return (
34+
<MenuItem
35+
key={`menu-group-${groupIndex}-item-${itemIndex}`}
36+
data-text={item.label}
37+
data-testid={`context-menu-item-${item.label}`}
38+
onClick={(evt: React.MouseEvent) => {
39+
console.log('clicked', evt);
40+
item.onAction?.(evt);
41+
}}
42+
>
43+
{item.label} {itemIndex} {groupIndex}
44+
</MenuItem>
45+
);
46+
})}
47+
{groupIndex < itemGroups.length - 1 && (
48+
<MenuSeparator key={`${groupIndex}-separator`} />
49+
)}
50+
</div>
4351
);
4452
})}
4553
</Menu>
@@ -49,6 +57,6 @@ export function ContextMenu({
4957
export function useContextMenuItems(
5058
items: ContextMenuItem[]
5159
): React.RefCallback<HTMLElement> {
52-
const contextMenu = useContextMenu({ Menu: ContextMenu });
60+
const contextMenu = useContextMenu();
5361
return contextMenu.registerItems(items);
5462
}

packages/compass-components/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,11 @@ export { ModalHeader } from './components/modals/modal-header';
9393
export { FormModal } from './components/modals/form-modal';
9494
export { InfoModal } from './components/modals/info-modal';
9595

96+
export {
97+
ContextMenuProvider,
98+
useContextMenuItems,
99+
} from './components/context-menu';
100+
96101
export type {
97102
FileInputBackend,
98103
ItemAction,
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import React from 'react';
2+
3+
type ContextMenuProps = React.PropsWithChildren<{
4+
position: {
5+
x: number;
6+
y: number;
7+
};
8+
}>;
9+
10+
export function ContextMenu({ children, position }: ContextMenuProps) {
11+
console.log('ContextMenu', position);
12+
return (
13+
<div
14+
style={{
15+
position: 'absolute',
16+
pointerEvents: 'all',
17+
left: position.x,
18+
top: position.y,
19+
}}
20+
>
21+
{children}
22+
</div>
23+
);
24+
}
Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,24 @@
1+
import type { ContextMenuItemGroup } from './types';
2+
13
const CONTEXT_MENUS_SYMBOL = Symbol('context_menus');
24

35
export type EnhancedMouseEvent = MouseEvent & {
4-
[CONTEXT_MENUS_SYMBOL]?: React.ComponentType[];
6+
[CONTEXT_MENUS_SYMBOL]?: ContextMenuItemGroup[];
57
};
68

79
export function getContextMenuContent(
810
event: EnhancedMouseEvent
9-
): React.ComponentType[] {
11+
): ContextMenuItemGroup[] {
1012
return event[CONTEXT_MENUS_SYMBOL] ?? [];
1113
}
1214

1315
export function appendContextMenuContent(
1416
event: EnhancedMouseEvent,
15-
content: React.ComponentType
17+
content: ContextMenuItemGroup
1618
) {
1719
// Initialize if not already patched
18-
if (event[CONTEXT_MENUS_SYMBOL] === undefined) {
19-
event[CONTEXT_MENUS_SYMBOL] = [content];
20-
return;
20+
if (!event[CONTEXT_MENUS_SYMBOL]) {
21+
event[CONTEXT_MENUS_SYMBOL] = [];
2122
}
2223
event[CONTEXT_MENUS_SYMBOL].push(content);
2324
}

packages/compass-context-menu/src/context-menu-provider.tsx

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,29 +5,35 @@ import React, {
55
useMemo,
66
createContext,
77
} from 'react';
8-
import type { ContextMenuContext, MenuState } from './types';
9-
import { ContextMenu } from './context-menu';
8+
import type {
9+
ContextMenuContext,
10+
ContextMenuItemGroup,
11+
ContextMenuState,
12+
} from './types';
13+
import { ContextMenu } from './compass-context-menu';
1014
import type { EnhancedMouseEvent } from './context-menu-content';
1115
import { getContextMenuContent } from './context-menu-content';
1216

1317
export const Context = createContext<ContextMenuContext | null>(null);
1418

1519
export function ContextMenuProvider({
1620
children,
21+
wrapper,
1722
}: {
1823
children: React.ReactNode;
24+
wrapper: React.ComponentType<{ itemGroups: ContextMenuItemGroup[] }>;
1925
}) {
20-
const [menu, setMenu] = useState<MenuState>({ isOpen: false });
26+
const [menu, setMenu] = useState<ContextMenuState>({ isOpen: false });
2127
const close = useCallback(() => setMenu({ isOpen: false }), [setMenu]);
2228

2329
useEffect(() => {
2430
function handleContextMenu(event: MouseEvent) {
31+
console.log('handleContextMenu', event);
2532
event.preventDefault();
33+
2634
setMenu({
2735
isOpen: true,
28-
children: getContextMenuContent(event as EnhancedMouseEvent).map(
29-
(Content, index) => <Content key={`menu-content-${index}`} />
30-
),
36+
itemGroups: getContextMenuContent(event as EnhancedMouseEvent),
3137
position: {
3238
// TODO: Fix handling offset while scrolling
3339
x: event.clientX,
@@ -37,16 +43,20 @@ export function ContextMenuProvider({
3743
}
3844

3945
function handleClosingEvent(event: Event) {
46+
console.log('handleClosingEvent', event);
4047
if (!event.defaultPrevented) {
48+
console.log('setting menu to false');
4149
setMenu({ isOpen: false });
4250
}
4351
}
4452

53+
console.log('adding event listeners');
4554
document.addEventListener('contextmenu', handleContextMenu);
46-
document.addEventListener('click', handleClosingEvent);
55+
window.addEventListener('click', handleClosingEvent);
4756
window.addEventListener('resize', handleClosingEvent);
4857

4958
return () => {
59+
console.log('removing event listeners');
5060
document.removeEventListener('contextmenu', handleContextMenu);
5161
document.removeEventListener('click', handleClosingEvent);
5262
window.removeEventListener('resize', handleClosingEvent);
@@ -60,12 +70,18 @@ export function ContextMenuProvider({
6070
[close]
6171
);
6272

73+
const Wrapper = wrapper ?? React.Fragment;
74+
6375
return (
64-
<>
65-
<Context.Provider value={value}>{children}</Context.Provider>
66-
{menu.isOpen && (
67-
<ContextMenu position={menu.position}>{menu.children}</ContextMenu>
68-
)}
69-
</>
76+
<Context.Provider value={value}>
77+
<>
78+
{children}
79+
{menu.isOpen && (
80+
<ContextMenu position={menu.position}>
81+
<Wrapper itemGroups={menu.itemGroups} />
82+
</ContextMenu>
83+
)}
84+
</>
85+
</Context.Provider>
7086
);
7187
}

0 commit comments

Comments
 (0)