diff --git a/packages/grid/src/Grid.tsx b/packages/grid/src/Grid.tsx index 738d78de99..0190906267 100644 --- a/packages/grid/src/Grid.tsx +++ b/packages/grid/src/Grid.tsx @@ -1552,6 +1552,10 @@ class Grid extends PureComponent { 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({ diff --git a/packages/grid/src/key-handlers/PasteKeyHandler.test.tsx b/packages/grid/src/key-handlers/PasteKeyHandler.test.tsx index 3783aceb96..9a749c6327 100644 --- a/packages/grid/src/key-handlers/PasteKeyHandler.test.tsx +++ b/packages/grid/src/key-handlers/PasteKeyHandler.test.tsx @@ -66,13 +66,13 @@ describe('table parsing', () => { const TEXT_TABLE_FIREFOX = ( <> - A    B    C + A    B    C
- 1    2    3 + 1    2    3 ); - const SINGLE_ROW_FIREFOX = <>A    B    C; + const SINGLE_ROW_FIREFOX = <>A    B    C; function testTable(jsx: JSX.Element, expectedValue: string[][]) { const element = makeElementFromJsx(jsx); diff --git a/packages/grid/src/key-handlers/PasteKeyHandler.ts b/packages/grid/src/key-handlers/PasteKeyHandler.ts index d8a1f281f4..168afe70cc 100644 --- a/packages/grid/src/key-handlers/PasteKeyHandler.ts +++ b/packages/grid/src/key-handlers/PasteKeyHandler.ts @@ -39,10 +39,9 @@ export function parseValueFromNodes(nodes: NodeListOf): 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; } @@ -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 && diff --git a/packages/iris-grid/src/IrisGrid.tsx b/packages/iris-grid/src/IrisGrid.tsx index c108d12c66..937eb7394c 100644 --- a/packages/iris-grid/src/IrisGrid.tsx +++ b/packages/iris-grid/src/IrisGrid.tsx @@ -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'); @@ -442,6 +443,8 @@ export interface IrisGridState { toastMessage: JSX.Element | null; frozenColumns: readonly ColumnName[]; showOverflowModal: boolean; + showNoPastePermissionModal: boolean; + noPastePermissionError: string; overflowText: string; overflowButtonTooltipProps: CSSProperties | null; expandCellTooltipProps: CSSProperties | null; @@ -624,6 +627,8 @@ class IrisGrid extends Component { 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); @@ -870,6 +875,8 @@ class IrisGrid extends Component { toastMessage: null, frozenColumns, showOverflowModal: false, + showNoPastePermissionModal: false, + noPastePermissionError: '', overflowText: '', overflowButtonTooltipProps: null, expandCellTooltipProps: null, @@ -3853,6 +3860,19 @@ class IrisGrid extends Component { }); } + handleOpenNoPastePermissionModal(errorMessage: string): void { + this.setState({ + showNoPastePermissionModal: true, + noPastePermissionError: errorMessage, + }); + } + + handleCloseNoPastePermissionModal(): void { + this.setState({ + showNoPastePermissionModal: false, + }); + } + getColumnBoundingRect(): DOMRect { const { metrics, shownColumnTooltip } = this.state; assertNotNull(metrics); @@ -4273,6 +4293,8 @@ class IrisGrid extends Component { frozenColumns, columnHeaderGroups, showOverflowModal, + showNoPastePermissionModal, + noPastePermissionError, overflowText, overflowButtonTooltipProps, expandCellTooltipProps, @@ -4998,6 +5020,11 @@ class IrisGrid extends Component { + ); } diff --git a/packages/iris-grid/src/NoPastePermissionModal.tsx b/packages/iris-grid/src/NoPastePermissionModal.tsx new file mode 100644 index 0000000000..8077bd588c --- /dev/null +++ b/packages/iris-grid/src/NoPastePermissionModal.tsx @@ -0,0 +1,36 @@ +import { + Button, + GLOBAL_SHORTCUTS, + Modal, + ModalBody, + ModalFooter, + ModalHeader, +} from '@deephaven/components'; + +export type NoPastePermissionModalProps = { + isOpen: boolean; + onClose: () => void; + errorMessage: string; +}; + +export function NoPastePermissionModal({ + isOpen, + onClose, + errorMessage, +}: NoPastePermissionModalProps): JSX.Element { + const pasteShortcutText = GLOBAL_SHORTCUTS.PASTE.getDisplayText(); + return ( + + No Paste Permission + +

{errorMessage}

+

You can still use {pasteShortcutText} to paste.

+
+ + + +
+ ); +} diff --git a/packages/iris-grid/src/mousehandlers/IrisGridContextMenuHandler.tsx b/packages/iris-grid/src/mousehandlers/IrisGridContextMenuHandler.tsx index 5134bf98d0..0166ec99a5 100644 --- a/packages/iris-grid/src/mousehandlers/IrisGridContextMenuHandler.tsx +++ b/packages/iris-grid/src/mousehandlers/IrisGridContextMenuHandler.tsx @@ -40,11 +40,14 @@ import { import Log from '@deephaven/log'; import type { DebouncedFunc } from 'lodash'; import { + ClipboardPermissionsDeniedError, + ClipboardUnavailableError, TextUtils, assertNotEmpty, assertNotNaN, assertNotNull, copyToClipboard, + readFromClipboard, } from '@deephaven/utils'; import { DateTimeFormatContextMenu, @@ -477,6 +480,31 @@ class IrisGridContextMenuHandler extends GridMouseHandler { }); } + actions.push({ + title: 'Paste', + group: IrisGridContextMenuHandler.GROUP_COPY, + order: 50, + action: async () => { + try { + const text = await readFromClipboard(); + const items = text.split('\n').map(row => row.split('\t')); + await grid.pasteValue(items); + } catch (err) { + if (err instanceof ClipboardUnavailableError) { + irisGrid.handleOpenNoPastePermissionModal( + 'For security reasons your browser does not allow access to your clipboard on click.' + ); + } else if (err instanceof ClipboardPermissionsDeniedError) { + irisGrid.handleOpenNoPastePermissionModal( + 'Requested clipboard permissions have not been granted, please grant them and try again.' + ); + } else { + throw err; + } + } + }, + }); + actions.push({ title: 'View Cell Contents', group: IrisGridContextMenuHandler.GROUP_VIEW_CONTENTS, diff --git a/packages/utils/src/ClipboardUtils.test.ts b/packages/utils/src/ClipboardUtils.test.ts index 20210cbea0..e65b740728 100644 --- a/packages/utils/src/ClipboardUtils.test.ts +++ b/packages/utils/src/ClipboardUtils.test.ts @@ -1,7 +1,16 @@ -import { copyToClipboard } from './ClipboardUtils'; +import { copyToClipboard, readFromClipboard } from './ClipboardUtils'; +import { + ClipboardPermissionsDeniedError, + ClipboardUnavailableError, +} from './errors'; +import { checkPermission } from './PermissionUtils'; document.execCommand = jest.fn(); +jest.mock('./PermissionUtils', () => ({ + checkPermission: jest.fn(), +})); + describe('Clipboard', () => { describe('writeText', () => { beforeEach(() => jest.resetAllMocks()); @@ -55,4 +64,113 @@ describe('Clipboard', () => { await expect(document.execCommand).toHaveBeenCalledWith('copy'); }); }); + + describe('readFromClipboard', () => { + beforeEach(() => jest.resetAllMocks()); + + it('should throw unavailable error if clipboard is undefined', async () => { + Object.assign(navigator, { + clipboard: undefined, + }); + + await expect(readFromClipboard()).rejects.toThrow( + ClipboardUnavailableError + ); + }); + + it('should throw unavailable error if PermissionState is null', async () => { + (checkPermission as jest.Mock).mockResolvedValue(null); + + await expect(readFromClipboard()).rejects.toThrow( + ClipboardUnavailableError + ); + }); + + it('should return text if PermissionState 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 throw denied error if PermissionState is denied', async () => { + Object.assign(navigator, { + clipboard: { + readText: jest.fn(), + }, + }); + + (checkPermission as jest.Mock).mockResolvedValue('denied'); + + await expect(readFromClipboard()).rejects.toThrow( + ClipboardPermissionsDeniedError + ); + }); + + 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 throw denied error 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()).rejects.toThrow( + ClipboardPermissionsDeniedError + ); + expect(checkPermission).toHaveBeenCalledTimes(2); + expect(mockClipboard.readText).toHaveBeenCalledTimes(1); + }); + + it('should throw denied error if permission prompt closed', async () => { + const mockClipboard = { + readText: jest.fn().mockRejectedValue(new Error('Missing permission')), + }; + + Object.assign(navigator, { + clipboard: mockClipboard, + }); + + (checkPermission as jest.Mock).mockResolvedValue('prompt'); + + await expect(readFromClipboard()).rejects.toThrow( + ClipboardPermissionsDeniedError + ); + expect(checkPermission).toHaveBeenCalledTimes(2); + expect(mockClipboard.readText).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/packages/utils/src/ClipboardUtils.ts b/packages/utils/src/ClipboardUtils.ts index 096d9177ab..82445660a6 100644 --- a/packages/utils/src/ClipboardUtils.ts +++ b/packages/utils/src/ClipboardUtils.ts @@ -1,3 +1,10 @@ +import { + ClipboardPermissionsDeniedError, + ClipboardUnavailableError, +} from './errors'; +import UnsupportedPermissionError from './errors/UnsupportedPermissionError'; +import { checkPermission } from './PermissionUtils'; + /** * Copy the passed in text to the clipboard. * @param text The text to copy @@ -38,3 +45,50 @@ 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 rejects if permissions are not supported/granted. + */ +export async function readFromClipboard(): Promise { + try { + const { clipboard } = navigator; + if (clipboard === undefined) throw new ClipboardUnavailableError(); + + 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, also treat it as a denial + throw new ClipboardPermissionsDeniedError(); + } + } catch (err: unknown) { + if (err instanceof UnsupportedPermissionError) { + throw new ClipboardUnavailableError(); + } + + throw err; + } + + throw new ClipboardUnavailableError(); +} diff --git a/packages/utils/src/PermissionUtils.test.ts b/packages/utils/src/PermissionUtils.test.ts new file mode 100644 index 0000000000..6d70cb4702 --- /dev/null +++ b/packages/utils/src/PermissionUtils.test.ts @@ -0,0 +1,33 @@ +import UnsupportedPermissionError from './errors/UnsupportedPermissionError'; +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 }), + }, + }); + + await expect(checkPermission('')).resolves.toEqual(state); + } + ); + + it('should throw error if permission is unsupported by the browser', async () => { + Object.assign(navigator, { + permissions: { + query: jest + .fn() + .mockRejectedValue(new TypeError('Permission not supported')), + }, + }); + + await expect(checkPermission('')).rejects.toThrow( + UnsupportedPermissionError + ); + }); +}); diff --git a/packages/utils/src/PermissionUtils.ts b/packages/utils/src/PermissionUtils.ts new file mode 100644 index 0000000000..24f0e21f98 --- /dev/null +++ b/packages/utils/src/PermissionUtils.ts @@ -0,0 +1,26 @@ +import UnsupportedPermissionError from './errors/UnsupportedPermissionError'; + +/** + * Checks permission to use a particular API from: https://developer.mozilla.org/en-US/docs/Web/API/Permissions_API#permission-aware_apis + * @param permission The name of the permission + * @returns Promise that resolves to PermissionState on success, or rejects if permission is unsupported by browser + */ +// eslint-disable-next-line import/prefer-default-export +export async function checkPermission( + permission: string +): Promise { + try { + // Typescript doesn't recognize certain permissions as a valid PermissionName + // https://github.com/microsoft/TypeScript/issues/33923 + const name = permission as PermissionName; + return (await navigator.permissions.query({ name })).state; + } catch (error: unknown) { + // TypeError thrown if retrieving the PermissionDescriptor information failed in some way, + // or the permission doesn't exist or is unsupported by the user agent. + if (error instanceof TypeError) { + throw new UnsupportedPermissionError(); + } + + throw error; + } +} diff --git a/packages/utils/src/errors/ClipboardPermissionDeniedError.ts b/packages/utils/src/errors/ClipboardPermissionDeniedError.ts new file mode 100644 index 0000000000..85b03ace9f --- /dev/null +++ b/packages/utils/src/errors/ClipboardPermissionDeniedError.ts @@ -0,0 +1,5 @@ +export class ClipboardPermissionsDeniedError extends Error { + isClipboardPermissionsDeniedError = true; +} + +export default ClipboardPermissionsDeniedError; diff --git a/packages/utils/src/errors/ClipboardUnavailableError.ts b/packages/utils/src/errors/ClipboardUnavailableError.ts new file mode 100644 index 0000000000..a4b0f7606a --- /dev/null +++ b/packages/utils/src/errors/ClipboardUnavailableError.ts @@ -0,0 +1,5 @@ +export class ClipboardUnavailableError extends Error { + isClipboardUnavailableError = true; +} + +export default ClipboardUnavailableError; diff --git a/packages/utils/src/errors/UnsupportedPermissionError.ts b/packages/utils/src/errors/UnsupportedPermissionError.ts new file mode 100644 index 0000000000..f25ab8b411 --- /dev/null +++ b/packages/utils/src/errors/UnsupportedPermissionError.ts @@ -0,0 +1,5 @@ +export class UnsupportedPermissionError extends Error { + isPermissionUnsupported = true; +} + +export default UnsupportedPermissionError; diff --git a/packages/utils/src/errors/index.ts b/packages/utils/src/errors/index.ts new file mode 100644 index 0000000000..f878beea96 --- /dev/null +++ b/packages/utils/src/errors/index.ts @@ -0,0 +1,2 @@ +export { default as ClipboardPermissionsDeniedError } from './ClipboardPermissionDeniedError'; +export { default as ClipboardUnavailableError } from './ClipboardUnavailableError'; diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index c4ed91f522..012a97f473 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -25,3 +25,4 @@ export * from './TypeUtils'; export { default as InvalidMetadataError } from './InvalidMetadataError'; export { default as ValidationError } from './ValidationError'; export * from './UIConstants'; +export * from './errors';