Skip to content

Commit 2ea1a11

Browse files
authored
feat(components): add right click copy/cut/paste context menu COMPASS-9919 (#7420)
1 parent 08662c4 commit 2ea1a11

File tree

7 files changed

+505
-11
lines changed

7 files changed

+505
-11
lines changed

configs/testing-library-compass/src/index.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,13 @@ function unwrapContextMenuContainer(result: RenderResult) {
422422
firstChild instanceof HTMLElement &&
423423
firstChild.getAttribute('data-testid') === 'context-menu-children-container'
424424
) {
425+
if (
426+
firstChild.firstChild instanceof HTMLElement &&
427+
firstChild.firstChild.getAttribute('data-testid') ===
428+
'copy-paste-context-menu-container'
429+
) {
430+
return { container: firstChild.firstChild, ...rest };
431+
}
425432
return { container: firstChild, ...rest };
426433
} else {
427434
return { container, ...rest };

packages/compass-components/src/components/compass-components-provider.tsx

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
ContextMenuProvider,
1313
} from './context-menu';
1414
import { DrawerContentProvider } from './drawer-portal';
15+
import { CopyPasteContextMenu } from '../hooks/use-copy-paste-context-menu';
1516

1617
type GuideCueProviderProps = React.ComponentProps<typeof GuideCueProvider>;
1718

@@ -181,15 +182,17 @@ export const CompassComponentsProvider = ({
181182
onContextMenuOpen={onContextMenuOpen}
182183
onContextMenuItemClick={onContextMenuItemClick}
183184
>
184-
<ToastArea>
185-
{typeof children === 'function'
186-
? children({
187-
darkMode,
188-
portalContainerRef: setPortalContainer,
189-
scrollContainerRef: setScrollContainer,
190-
})
191-
: children}
192-
</ToastArea>
185+
<CopyPasteContextMenu>
186+
<ToastArea>
187+
{typeof children === 'function'
188+
? children({
189+
darkMode,
190+
portalContainerRef: setPortalContainer,
191+
scrollContainerRef: setScrollContainer,
192+
})
193+
: children}
194+
</ToastArea>
195+
</CopyPasteContextMenu>
193196
</ContextMenuProvider>
194197
</ConfirmationModalArea>
195198
</SignalHooksProvider>

packages/compass-components/src/components/content-with-fallback.spec.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,15 @@ describe('ContentWithFallback', function () {
6262
const [contentContainer, contextMenuContainer] = Array.from(
6363
container.children
6464
);
65-
expect(contentContainer.children.length).to.equal(0);
65+
expect(contentContainer.children.length).to.equal(1);
6666
expect(contextMenuContainer.getAttribute('data-testid')).to.equal(
6767
'context-menu-container'
6868
);
69+
const copyPasteContextMenu = contentContainer.children[0];
70+
expect(copyPasteContextMenu.children.length).to.equal(0);
71+
expect(copyPasteContextMenu.getAttribute('data-testid')).to.equal(
72+
'copy-paste-context-menu-container'
73+
);
6974
});
7075

7176
it('should render fallback when the timeout passes', async function () {

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,11 @@ export function ContextMenu({
119119
data-text={item.label}
120120
data-testid={`menu-group-${groupIndex}-item-${itemIndex}`}
121121
className={itemStyles}
122+
onMouseDown={(evt: React.MouseEvent) => {
123+
// Keep focus on the element that was right-clicked to open the menu.
124+
evt.preventDefault();
125+
evt.stopPropagation();
126+
}}
122127
onClick={(evt: React.MouseEvent) => {
123128
item.onAction?.(evt);
124129
onContextMenuItemClick?.(itemGroup, item);
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
import React from 'react';
2+
import {
3+
render,
4+
screen,
5+
userEvent,
6+
waitFor,
7+
} from '@mongodb-js/testing-library-compass';
8+
import { expect } from 'chai';
9+
import sinon from 'sinon';
10+
11+
describe('useCopyPasteContextMenu', function () {
12+
afterEach(function () {
13+
sinon.restore();
14+
});
15+
16+
const TestComponent = () => {
17+
// The copy-paste functionality is already provided through the
18+
// test rendering hook. So we only render a few elements
19+
// that can be interacted with.
20+
return (
21+
<div data-testid="test-container">
22+
<input
23+
data-testid="test-input"
24+
type="text"
25+
defaultValue="Hello World"
26+
/>
27+
<textarea data-testid="test-textarea" defaultValue="Textarea content" />
28+
<div data-testid="test-readonly">Read-only content</div>
29+
</div>
30+
);
31+
};
32+
33+
describe('without the clipboard API', function () {
34+
it('does not show any actions', function () {
35+
sinon.replaceGetter(
36+
global,
37+
'navigator',
38+
(() => ({})) as unknown as () => typeof global.navigator
39+
);
40+
41+
render(<TestComponent />);
42+
43+
const testInput: HTMLInputElement = screen.getByTestId('test-input');
44+
userEvent.click(testInput);
45+
testInput.setSelectionRange(0, 5);
46+
47+
userEvent.click(testInput, { button: 2 });
48+
49+
expect(screen.queryByText('Cut')).to.not.exist;
50+
expect(screen.queryByText('Copy')).to.not.exist;
51+
expect(screen.queryByText('Paste')).to.not.exist;
52+
});
53+
});
54+
55+
describe('with stubbed clipboard actions', function () {
56+
let mockClipboard: {
57+
writeText: sinon.SinonStub;
58+
readText: sinon.SinonStub;
59+
};
60+
let setExecCommand: boolean = false;
61+
beforeEach(function () {
62+
mockClipboard = {
63+
writeText: sinon.stub().resolves(),
64+
readText: sinon.stub().resolves('pasted text'),
65+
};
66+
67+
// The execCommand doesn't exist in the testing environment.
68+
// https://github.com/jsdom/jsdom/issues/1742
69+
if (!document.execCommand) {
70+
setExecCommand = true;
71+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
72+
(document as any).execCommand = () => true;
73+
}
74+
sinon.stub(document, 'execCommand').returns(true);
75+
sinon
76+
.stub(global.navigator.clipboard, 'writeText')
77+
.callsFake(mockClipboard.writeText);
78+
sinon
79+
.stub(global.navigator.clipboard, 'readText')
80+
.callsFake(mockClipboard.readText);
81+
});
82+
83+
afterEach(function () {
84+
sinon.restore();
85+
if (setExecCommand) {
86+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
87+
delete (document as any).execCommand;
88+
setExecCommand = false;
89+
}
90+
});
91+
92+
describe('context menu visibility', function () {
93+
it('shows paste when focusing editable element', async function () {
94+
render(<TestComponent />);
95+
96+
const input = screen.getByTestId('test-input');
97+
userEvent.click(input);
98+
userEvent.click(input, { button: 2 });
99+
100+
await waitFor(() => {
101+
// No selection, so no cut/copy.
102+
expect(screen.queryByText('Cut')).to.not.exist;
103+
expect(screen.queryByText('Copy')).to.not.exist;
104+
105+
expect(screen.getByText('Paste')).to.be.visible;
106+
});
107+
});
108+
});
109+
110+
describe('clipboard operations', function () {
111+
it('calls clipboard writeText when copying', async function () {
112+
render(<TestComponent />);
113+
114+
const testInput: HTMLInputElement = screen.getByTestId('test-input');
115+
userEvent.click(testInput);
116+
userEvent.type(testInput, '12345');
117+
118+
testInput.setSelectionRange(6, 14);
119+
120+
const selectedText = testInput.value.substring(
121+
testInput.selectionStart || 0,
122+
testInput.selectionEnd || 0
123+
);
124+
expect(selectedText).to.equal('World123');
125+
126+
userEvent.click(testInput, { button: 2 });
127+
128+
await waitFor(() => {
129+
expect(screen.getByText('Copy')).to.be.visible;
130+
});
131+
132+
userEvent.click(screen.getByText('Copy'));
133+
134+
await waitFor(() => {
135+
expect(mockClipboard.writeText).to.have.been.calledOnceWith(
136+
'World123'
137+
);
138+
});
139+
});
140+
141+
it('calls clipboard writeText when cutting', async function () {
142+
render(<TestComponent />);
143+
144+
const testInput: HTMLInputElement = screen.getByTestId('test-input');
145+
userEvent.click(testInput);
146+
userEvent.type(testInput, '12345');
147+
148+
testInput.setSelectionRange(6, 14);
149+
150+
const selectedText = testInput.value.substring(
151+
testInput.selectionStart || 0,
152+
testInput.selectionEnd || 0
153+
);
154+
expect(selectedText).to.equal('World123');
155+
156+
userEvent.click(testInput, { button: 2 });
157+
158+
await waitFor(() => {
159+
expect(screen.getByText('Cut')).to.be.visible;
160+
});
161+
162+
userEvent.click(screen.getByText('Cut'));
163+
164+
await waitFor(() => {
165+
expect(mockClipboard.writeText).to.have.been.calledWith('World123');
166+
});
167+
});
168+
169+
it('calls clipboard readText when pasting', async function () {
170+
render(<TestComponent />);
171+
172+
const input = screen.getByTestId('test-input');
173+
userEvent.click(input);
174+
userEvent.click(input, { button: 2 });
175+
176+
await waitFor(() => {
177+
expect(screen.getByText('Paste')).to.be.visible;
178+
});
179+
180+
expect(mockClipboard.readText).to.not.have.been.called;
181+
182+
userEvent.click(screen.getByText('Paste'));
183+
184+
await waitFor(() => {
185+
expect(mockClipboard.readText).to.have.been.called;
186+
});
187+
});
188+
189+
it('handles clipboard errors gracefully', async function () {
190+
mockClipboard.readText.rejects(new Error('Permission denied'));
191+
192+
render(<TestComponent />);
193+
194+
const input = screen.getByTestId('test-input');
195+
userEvent.click(input);
196+
userEvent.click(input, { button: 2 });
197+
198+
await waitFor(() => {
199+
const pasteButton = screen.getByText('Paste');
200+
201+
expect(() => userEvent.click(pasteButton)).to.not.throw();
202+
});
203+
});
204+
});
205+
206+
describe('element type detection', function () {
207+
it('detects input elements as editable', function () {
208+
render(<TestComponent />);
209+
210+
const input = screen.getByTestId('test-input');
211+
userEvent.click(input);
212+
userEvent.click(input, { button: 2 });
213+
214+
expect(screen.queryByText('Cut')).to.not.exist;
215+
expect(screen.getByText('Paste')).to.be.visible;
216+
});
217+
218+
it('detects textarea elements as editable', function () {
219+
render(<TestComponent />);
220+
221+
const textarea = screen.getByTestId('test-textarea');
222+
userEvent.click(textarea);
223+
userEvent.click(textarea, { button: 2 });
224+
225+
expect(screen.queryByText('Cut')).to.not.exist;
226+
expect(screen.getByText('Paste')).to.be.visible;
227+
});
228+
229+
it('detects readonly elements as non-editable for paste', function () {
230+
render(<TestComponent />);
231+
232+
const readOnly = screen.getByTestId('test-readonly');
233+
userEvent.click(readOnly);
234+
userEvent.click(readOnly, { button: 2 });
235+
236+
expect(screen.queryByText('Paste')).to.not.exist;
237+
});
238+
239+
it('handles non-text input types', function () {
240+
render(<input data-testid="checkbox-input" type="checkbox" />);
241+
242+
const input = screen.getByTestId('checkbox-input');
243+
userEvent.click(input);
244+
userEvent.click(input, { button: 2 });
245+
246+
expect(screen.queryByText('Paste')).to.not.exist;
247+
});
248+
});
249+
});
250+
});

0 commit comments

Comments
 (0)