Skip to content
Open
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
7 changes: 7 additions & 0 deletions configs/testing-library-compass/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,13 @@ function unwrapContextMenuContainer(result: RenderResult) {
firstChild instanceof HTMLElement &&
firstChild.getAttribute('data-testid') === 'context-menu-children-container'
) {
if (
firstChild.firstChild instanceof HTMLElement &&
firstChild.firstChild.getAttribute('data-testid') ===
'copy-paste-context-menu-container'
) {
return { container: firstChild.firstChild, ...rest };
}
return { container: firstChild, ...rest };
} else {
return { container, ...rest };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
ContextMenuProvider,
} from './context-menu';
import { DrawerContentProvider } from './drawer-portal';
import { CopyPasteContextMenu } from '../hooks/use-copy-paste-context-menu';

type GuideCueProviderProps = React.ComponentProps<typeof GuideCueProvider>;

Expand Down Expand Up @@ -174,15 +175,17 @@ export const CompassComponentsProvider = ({
onContextMenuOpen={onContextMenuOpen}
onContextMenuItemClick={onContextMenuItemClick}
>
<ToastArea>
{typeof children === 'function'
? children({
darkMode,
portalContainerRef: setPortalContainer,
scrollContainerRef: setScrollContainer,
})
: children}
</ToastArea>
<CopyPasteContextMenu>
<ToastArea>
{typeof children === 'function'
? children({
darkMode,
portalContainerRef: setPortalContainer,
scrollContainerRef: setScrollContainer,
})
: children}
</ToastArea>
</CopyPasteContextMenu>
</ContextMenuProvider>
</ConfirmationModalArea>
</SignalHooksProvider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,15 @@ describe('ContentWithFallback', function () {
const [contentContainer, contextMenuContainer] = Array.from(
container.children
);
expect(contentContainer.children.length).to.equal(0);
expect(contentContainer.children.length).to.equal(1);
expect(contextMenuContainer.getAttribute('data-testid')).to.equal(
'context-menu-container'
);
const copyPasteContextMenu = contentContainer.children[0];
expect(copyPasteContextMenu.children.length).to.equal(0);
expect(copyPasteContextMenu.getAttribute('data-testid')).to.equal(
'copy-paste-context-menu-container'
);
});

it('should render fallback when the timeout passes', async function () {
Expand Down
5 changes: 5 additions & 0 deletions packages/compass-components/src/components/context-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,11 @@ export function ContextMenu({
data-text={item.label}
data-testid={`menu-group-${groupIndex}-item-${itemIndex}`}
className={itemStyles}
onMouseDown={(evt: React.MouseEvent) => {
// Keep focus on the element that was right-clicked to open the menu.
evt.preventDefault();
evt.stopPropagation();
}}
onClick={(evt: React.MouseEvent) => {
item.onAction?.(evt);
onContextMenuItemClick?.(itemGroup, item);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
import React from 'react';
import {
render,
screen,
userEvent,
waitFor,
} from '@mongodb-js/testing-library-compass';
import { expect } from 'chai';
import sinon from 'sinon';

describe('useCopyPasteContextMenu', function () {
let mockClipboard: {
writeText: sinon.SinonStub;
readText: sinon.SinonStub;
};
let setExecCommand: boolean = false;
beforeEach(function () {
mockClipboard = {
writeText: sinon.stub().resolves(),
readText: sinon.stub().resolves('pasted text'),
};

// Create execCommand if it doesn't exist in test environment
if (!document.execCommand) {
setExecCommand = true;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(document as any).execCommand = () => true;
}
sinon.stub(document, 'execCommand').returns(true);
sinon
.stub(global.navigator.clipboard, 'writeText')
.callsFake(mockClipboard.writeText);
sinon
.stub(global.navigator.clipboard, 'readText')
.callsFake(mockClipboard.readText);
});

afterEach(function () {
sinon.restore();
if (setExecCommand) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (document as any).execCommand;
setExecCommand = false;
}
});

const TestComponent = () => {
// The copy-paste functionality is already provided through the
// test rendering hook. So we only render a few elements
// that can be interacted with.
return (
<div data-testid="test-container">
<input
data-testid="test-input"
type="text"
defaultValue="Hello World"
/>
<textarea data-testid="test-textarea" defaultValue="Textarea content" />
<div data-testid="test-readonly">Read-only content</div>
</div>
);
};

describe('context menu visibility', function () {
it('shows paste when focusing editable element', async function () {
render(<TestComponent />);

const input = screen.getByTestId('test-input');
userEvent.click(input);
userEvent.click(input, { button: 2 });

await waitFor(() => {
// No selection, so no cut/copy.
expect(screen.queryByText('Cut')).to.not.exist;
expect(screen.queryByText('Copy')).to.not.exist;

expect(screen.getByText('Paste')).to.be.visible;
});
});
});

describe('clipboard operations', function () {
it('calls clipboard writeText when copying', async function () {
render(<TestComponent />);

const testInput: HTMLInputElement = screen.getByTestId('test-input');
userEvent.click(testInput);
userEvent.type(testInput, '12345');

testInput.setSelectionRange(6, 14);

const selectedText = testInput.value.substring(
testInput.selectionStart || 0,
testInput.selectionEnd || 0
);
expect(selectedText).to.equal('World123');

userEvent.click(testInput, { button: 2 });

await waitFor(() => {
expect(screen.getByText('Copy')).to.be.visible;
});

userEvent.click(screen.getByText('Copy'));

await waitFor(() => {
expect(mockClipboard.writeText).to.have.been.calledOnceWith('World123');
});
});

it('calls clipboard writeText when cutting', async function () {
render(<TestComponent />);

const testInput: HTMLInputElement = screen.getByTestId('test-input');
userEvent.click(testInput);
userEvent.type(testInput, '12345');

testInput.setSelectionRange(6, 14);

const selectedText = testInput.value.substring(
testInput.selectionStart || 0,
testInput.selectionEnd || 0
);
expect(selectedText).to.equal('World123');

userEvent.click(testInput, { button: 2 });

await waitFor(() => {
expect(screen.getByText('Cut')).to.be.visible;
});

userEvent.click(screen.getByText('Cut'));

await waitFor(() => {
expect(mockClipboard.writeText).to.have.been.calledWith('World123');
});
});

it('calls clipboard readText when pasting', async function () {
render(<TestComponent />);

const input = screen.getByTestId('test-input');
userEvent.click(input);
userEvent.click(input, { button: 2 });

await waitFor(() => {
expect(screen.getByText('Paste')).to.be.visible;
});

expect(mockClipboard.readText).to.not.have.been.called;

userEvent.click(screen.getByText('Paste'));

await waitFor(() => {
expect(mockClipboard.readText).to.have.been.called;
});
});

it('handles clipboard errors gracefully', async function () {
mockClipboard.readText.rejects(new Error('Permission denied'));

render(<TestComponent />);

const input = screen.getByTestId('test-input');
userEvent.click(input);
userEvent.click(input, { button: 2 });

await waitFor(() => {
const pasteButton = screen.getByText('Paste');

expect(() => userEvent.click(pasteButton)).to.not.throw();
});
});

it('falls back to execCommand when clipboard API fails for cut', async function () {
mockClipboard.writeText.rejects(new Error('Permission denied'));

render(<TestComponent />);

const testInput: HTMLInputElement = screen.getByTestId('test-input');
userEvent.click(testInput);
testInput.setSelectionRange(0, 5);

userEvent.click(testInput, { button: 2 });

await waitFor(() => {
expect(screen.getByText('Cut')).to.be.visible;
});

userEvent.click(screen.getByText('Cut'));

await waitFor(() => {
expect(mockClipboard.writeText).to.have.been.called;
expect(document.execCommand).to.have.been.calledWith('cut');
});
});

it('falls back to execCommand when clipboard API fails for paste', async function () {
mockClipboard.readText.rejects(new Error('Permission denied'));

render(<TestComponent />);

const testInput: HTMLInputElement = screen.getByTestId('test-input');
userEvent.click(testInput);
testInput.setSelectionRange(0, 5);

userEvent.click(testInput, { button: 2 });

await waitFor(() => {
expect(screen.getByText('Paste')).to.be.visible;
});

expect(mockClipboard.readText).to.not.have.been.called;
userEvent.click(screen.getByText('Paste'));

await waitFor(() => {
expect(mockClipboard.readText).to.have.been.called;
expect(document.execCommand).to.have.been.calledWith('paste');
});
});
});

describe('element type detection', function () {
it('detects input elements as editable', function () {
render(<TestComponent />);

const input = screen.getByTestId('test-input');
userEvent.click(input);
userEvent.click(input, { button: 2 });

expect(screen.queryByText('Cut')).to.not.exist;
expect(screen.getByText('Paste')).to.be.visible;
});

it('detects textarea elements as editable', function () {
render(<TestComponent />);

const textarea = screen.getByTestId('test-textarea');
userEvent.click(textarea);
userEvent.click(textarea, { button: 2 });

expect(screen.queryByText('Cut')).to.not.exist;
expect(screen.getByText('Paste')).to.be.visible;
});

it('detects readonly elements as non-editable for paste', function () {
render(<TestComponent />);

const readOnly = screen.getByTestId('test-readonly');
userEvent.click(readOnly);
userEvent.click(readOnly, { button: 2 });

expect(screen.queryByText('Paste')).to.not.exist;
});

it('handles non-text input types', function () {
render(<input data-testid="checkbox-input" type="checkbox" />);

const input = screen.getByTestId('checkbox-input');
userEvent.click(input);
userEvent.click(input, { button: 2 });

expect(screen.queryByText('Paste')).to.not.exist;
});
});
});
Loading
Loading