Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: No context menu item for paste in an input table #2341

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
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
4 changes: 4 additions & 0 deletions packages/grid/src/Grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1552,6 +1552,10 @@ class Grid extends PureComponent<GridProps, GridState> {

const edits: EditOperation[] = [];
ranges.forEach(range => {
if ((range.startColumn ?? 0) + tableWidth > columnCount) {
throw new PasteError('Pasted content would overflow columns.');
}

for (let x = 0; x < tableWidth; x += 1) {
for (let y = 0; y < tableHeight; y += 1) {
edits.push({
Expand Down
6 changes: 3 additions & 3 deletions packages/grid/src/key-handlers/PasteKeyHandler.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,13 @@ describe('table parsing', () => {

const TEXT_TABLE_FIREFOX = (
<>
A&nbsp;&nbsp; &nbsp;B&nbsp;&nbsp; &nbsp;C
A&nbsp;&nbsp;&nbsp; B&nbsp;&nbsp;&nbsp; C
<br />
1&nbsp;&nbsp; &nbsp;2&nbsp;&nbsp; &nbsp;3
1&nbsp;&nbsp;&nbsp; 2&nbsp;&nbsp;&nbsp; 3
</>
);

const SINGLE_ROW_FIREFOX = <>A&nbsp;&nbsp; &nbsp;B&nbsp;&nbsp; &nbsp;C</>;
const SINGLE_ROW_FIREFOX = <>A&nbsp;&nbsp;&nbsp; B&nbsp;&nbsp;&nbsp; C</>;

function testTable(jsx: JSX.Element, expectedValue: string[][]) {
const element = makeElementFromJsx(jsx);
Expand Down
5 changes: 2 additions & 3 deletions packages/grid/src/key-handlers/PasteKeyHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,9 @@ export function parseValueFromNodes(nodes: NodeListOf<ChildNode>): string[][] {
if (text.length > 0) {
// When Chrome pastes a table from text, it preserves the tab characters
// In Firefox, it breaks it into a combination of non-breaking spaces and spaces
result.push(text.split(/\t|\u00a0\u00a0 \u00a0/));
result.push(text.split(/\t|\u00a0\u00a0\u00a0 /));
}
});

return result;
}

Expand All @@ -62,7 +61,7 @@ export function parseValueFromElement(
// If there's only one row and it doesn't contain a tab, then just treat it as a regular value
const { childNodes } = element;
const hasTabChar = text.includes('\t');
const hasFirefoxTab = text.includes('\u00a0\u00a0 \u00a0');
const hasFirefoxTab = text.includes('\u00a0\u00a0\u00a0 ');
if (
hasTabChar &&
childNodes.length !== 0 &&
Expand Down
22 changes: 22 additions & 0 deletions packages/iris-grid/src/IrisGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ import {
import type ColumnHeaderGroup from './ColumnHeaderGroup';
import { IrisGridThemeContext } from './IrisGridThemeProvider';
import { isMissingPartitionError } from './MissingPartitionError';
import { NoPastePermissionModal } from './NoPastePermissionModal';

const log = Log.module('IrisGrid');

Expand Down Expand Up @@ -442,6 +443,7 @@ export interface IrisGridState {
toastMessage: JSX.Element | null;
frozenColumns: readonly ColumnName[];
showOverflowModal: boolean;
showNoPastePermissionModal: boolean;
overflowText: string;
overflowButtonTooltipProps: CSSProperties | null;
expandCellTooltipProps: CSSProperties | null;
Expand Down Expand Up @@ -624,6 +626,8 @@ class IrisGrid extends Component<IrisGridProps, IrisGridState> {
this.handleCrossColumnSearch = this.handleCrossColumnSearch.bind(this);
this.handleRollupChange = this.handleRollupChange.bind(this);
this.handleOverflowClose = this.handleOverflowClose.bind(this);
this.handleCloseNoPastePermissionModal =
this.handleCloseNoPastePermissionModal.bind(this);
this.getColumnBoundingRect = this.getColumnBoundingRect.bind(this);
this.handleGotoRowSelectedRowNumberChanged =
this.handleGotoRowSelectedRowNumberChanged.bind(this);
Expand Down Expand Up @@ -870,6 +874,7 @@ class IrisGrid extends Component<IrisGridProps, IrisGridState> {
toastMessage: null,
frozenColumns,
showOverflowModal: false,
showNoPastePermissionModal: false,
overflowText: '',
overflowButtonTooltipProps: null,
expandCellTooltipProps: null,
Expand Down Expand Up @@ -3853,6 +3858,18 @@ class IrisGrid extends Component<IrisGridProps, IrisGridState> {
});
}

handleOpenNoPastePermissionModal(): void {
this.setState({
showNoPastePermissionModal: true,
});
}

handleCloseNoPastePermissionModal(): void {
this.setState({
showNoPastePermissionModal: false,
});
}

getColumnBoundingRect(): DOMRect {
const { metrics, shownColumnTooltip } = this.state;
assertNotNull(metrics);
Expand Down Expand Up @@ -4273,6 +4290,7 @@ class IrisGrid extends Component<IrisGridProps, IrisGridState> {
frozenColumns,
columnHeaderGroups,
showOverflowModal,
showNoPastePermissionModal,
overflowText,
overflowButtonTooltipProps,
expandCellTooltipProps,
Expand Down Expand Up @@ -4998,6 +5016,10 @@ class IrisGrid extends Component<IrisGridProps, IrisGridState> {
</div>
</SlideTransition>
<ContextActions actions={this.contextActions} />
<NoPastePermissionModal
isOpen={showNoPastePermissionModal}
handleClose={this.handleCloseNoPastePermissionModal}
/>
</div>
);
}
Expand Down
38 changes: 38 additions & 0 deletions packages/iris-grid/src/NoPastePermissionModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import {
Button,
GLOBAL_SHORTCUTS,
Modal,
ModalBody,
ModalFooter,
ModalHeader,
} from '@deephaven/components';

export type NoPastePermissionModalProps = {
isOpen: boolean;
handleClose: () => void;
};

export function NoPastePermissionModal({
isOpen,
handleClose,
}: NoPastePermissionModalProps): JSX.Element {
const pasteShortcutText = GLOBAL_SHORTCUTS.PASTE.getDisplayText();
return (
<Modal isOpen={isOpen} toggle={handleClose} centered>
<ModalHeader closeButton={false}>No Paste Permission</ModalHeader>
<ModalBody>
<p>
For security reasons your browser does not allow access to your
clipboard on click, or requested clipboard permissions have been
denied.
</p>
<p>You can still use {pasteShortcutText} to paste.</p>
</ModalBody>
<ModalFooter>
<Button kind="primary" onClick={handleClose}>
Dismiss
</Button>
</ModalFooter>
</Modal>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
assertNotNaN,
assertNotNull,
copyToClipboard,
readFromClipboard,
} from '@deephaven/utils';
import {
DateTimeFormatContextMenu,
Expand Down Expand Up @@ -477,6 +478,21 @@ class IrisGridContextMenuHandler extends GridMouseHandler {
});
}

actions.push({
title: 'Paste',
group: IrisGridContextMenuHandler.GROUP_COPY,
order: 50,
action: async () => {
const text = await readFromClipboard();
if (text !== null) {
const items = text.split('\n').map(row => row.split('\t'));
await grid.pasteValue(items);
} else {
irisGrid.handleOpenNoPastePermissionModal();
}
},
});

actions.push({
title: 'View Cell Contents',
group: IrisGridContextMenuHandler.GROUP_VIEW_CONTENTS,
Expand Down
108 changes: 107 additions & 1 deletion packages/utils/src/ClipboardUtils.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { copyToClipboard } from './ClipboardUtils';
import { copyToClipboard, readFromClipboard } from './ClipboardUtils';
import { checkPermission } from './PermissionUtils';

document.execCommand = jest.fn();

jest.mock('./PermissionUtils', () => ({
checkPermission: jest.fn(),
}));

describe('Clipboard', () => {
describe('writeText', () => {
beforeEach(() => jest.resetAllMocks());
Expand Down Expand Up @@ -55,4 +60,105 @@ describe('Clipboard', () => {
await expect(document.execCommand).toHaveBeenCalledWith('copy');
});
});

describe('readFromClipboard', () => {
beforeEach(() => jest.resetAllMocks());

it('should return null if clipboard is undefined', async () => {
Object.assign(navigator, {
clipboard: undefined,
});

await expect(readFromClipboard()).resolves.toBeNull();
});

it('should return null if PermissionState is null', async () => {
(checkPermission as jest.Mock).mockResolvedValue(null);

await expect(readFromClipboard()).resolves.toBeNull();
});

it('should return text if permission is granted', async () => {
Object.assign(navigator, {
clipboard: {
readText: jest.fn().mockResolvedValueOnce('text from clipboard'),
},
});

(checkPermission as jest.Mock).mockResolvedValueOnce('granted');

await expect(readFromClipboard()).resolves.toBe('text from clipboard');
});

it('should return null if permission is denied', async () => {
Object.assign(navigator, {
clipboard: {
readText: jest.fn(),
},
});

(checkPermission as jest.Mock).mockResolvedValue('denied');

await expect(readFromClipboard()).resolves.toBeNull();
});

it('should return text if permission prompt accepted', async () => {
const mockClipboard = {
readText: jest
.fn()
.mockRejectedValueOnce(new Error('Missing permission'))
.mockResolvedValueOnce('text from clipboard'),
};

Object.assign(navigator, {
clipboard: mockClipboard,
});

(checkPermission as jest.Mock)
.mockResolvedValueOnce('prompt')
.mockResolvedValue('granted');

await expect(readFromClipboard()).resolves.toBe('text from clipboard');
expect(checkPermission).toHaveBeenCalledTimes(2);
expect(mockClipboard.readText).toHaveBeenCalledTimes(2);
});

it('should return null if permission prompt denied', async () => {
const mockClipboard = {
readText: jest
.fn()
.mockRejectedValueOnce(new Error('Missing permission')),
};

Object.assign(navigator, {
clipboard: mockClipboard,
});

(checkPermission as jest.Mock)
.mockResolvedValueOnce('prompt')
.mockResolvedValue('denied');

await expect(readFromClipboard()).resolves.toBeNull();
expect(checkPermission).toHaveBeenCalledTimes(2);
expect(mockClipboard.readText).toHaveBeenCalledTimes(1);
});

it('should return null if permission prompt closed', async () => {
const mockClipboard = {
readText: jest
.fn()
.mockRejectedValueOnce(new Error('Missing permission')),
};

Object.assign(navigator, {
clipboard: mockClipboard,
});

(checkPermission as jest.Mock).mockResolvedValue('prompt');

await expect(readFromClipboard()).resolves.toBeNull();
expect(checkPermission).toHaveBeenCalledTimes(2);
expect(mockClipboard.readText).toHaveBeenCalledTimes(1);
});
});
});
41 changes: 41 additions & 0 deletions packages/utils/src/ClipboardUtils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { checkPermission } from './PermissionUtils';

/**
* Copy the passed in text to the clipboard.
* @param text The text to copy
Expand Down Expand Up @@ -38,3 +40,42 @@ export function copyToClipboardExecCommand(text: string): void {

if (!result) throw new Error('Unable to execute copy command');
}

/**
* Reads text from the clipboard.
* @returns Promise that resolves to text from clipboard as string, or null if permissions are not supported/granted.
*/
export async function readFromClipboard(): Promise<string | null> {
const { clipboard } = navigator;
if (clipboard === undefined) return null;

let permissionState = await checkPermission('clipboard-read');

if (permissionState === 'granted') {
const text = await clipboard.readText();
return text;
}

if (permissionState === 'prompt') {
try {
// Need to call this to bring up a permission prompt
await clipboard.readText();
} catch {
// Ignore error caused by calling readText() without permissions
}

// Check status again after user has interacted with the permission prompt
permissionState = await checkPermission('clipboard-read');
if (permissionState === 'granted') {
const text = await clipboard.readText();
return text;
}

if (permissionState === 'prompt' || permissionState === 'denied') {
// Prompt means user closed out of the previous permission prompt, treat it as a denial
return null;
}
}

return null;
}
32 changes: 32 additions & 0 deletions packages/utils/src/PermissionUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { checkPermission } from './PermissionUtils';

describe('checkPermission', () => {
beforeEach(() => jest.resetAllMocks());

it.each(['granted', 'prompt', 'denied'])(
'should return the correct PermissionState for %s permission state',
async state => {
Object.assign(navigator, {
permissions: {
query: jest.fn().mockResolvedValue({ state }),
},
});

const result = await checkPermission('');
expect(result).toEqual(state);
}
);

it('should return null if permission is unsupported by the browser', async () => {
Object.assign(navigator, {
permissions: {
query: jest
.fn()
.mockRejectedValue(new Error('Permission not supported')),
},
});

const result = await checkPermission('');
expect(result).toBeNull();
});
});
Loading
Loading