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

Merged
Show file tree
Hide file tree
Changes from 3 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
9 changes: 7 additions & 2 deletions packages/iris-grid/src/IrisGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,7 @@ export interface IrisGridState {
frozenColumns: readonly ColumnName[];
showOverflowModal: boolean;
showNoPastePermissionModal: boolean;
noPastePermissionError: string;
overflowText: string;
overflowButtonTooltipProps: CSSProperties | null;
expandCellTooltipProps: CSSProperties | null;
Expand Down Expand Up @@ -875,6 +876,7 @@ class IrisGrid extends Component<IrisGridProps, IrisGridState> {
frozenColumns,
showOverflowModal: false,
showNoPastePermissionModal: false,
noPastePermissionError: '',
overflowText: '',
overflowButtonTooltipProps: null,
expandCellTooltipProps: null,
Expand Down Expand Up @@ -3858,9 +3860,10 @@ class IrisGrid extends Component<IrisGridProps, IrisGridState> {
});
}

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

Expand Down Expand Up @@ -4291,6 +4294,7 @@ class IrisGrid extends Component<IrisGridProps, IrisGridState> {
columnHeaderGroups,
showOverflowModal,
showNoPastePermissionModal,
noPastePermissionError,
overflowText,
overflowButtonTooltipProps,
expandCellTooltipProps,
Expand Down Expand Up @@ -5018,7 +5022,8 @@ class IrisGrid extends Component<IrisGridProps, IrisGridState> {
<ContextActions actions={this.contextActions} />
<NoPastePermissionModal
isOpen={showNoPastePermissionModal}
handleClose={this.handleCloseNoPastePermissionModal}
onClose={this.handleCloseNoPastePermissionModal}
errorMessage={noPastePermissionError}
/>
</div>
);
Expand Down
16 changes: 7 additions & 9 deletions packages/iris-grid/src/NoPastePermissionModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,27 +9,25 @@ import {

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

export function NoPastePermissionModal({
isOpen,
handleClose,
onClose,
errorMessage,
}: NoPastePermissionModalProps): JSX.Element {
const pasteShortcutText = GLOBAL_SHORTCUTS.PASTE.getDisplayText();
return (
<Modal isOpen={isOpen} toggle={handleClose} centered>
<Modal isOpen={isOpen} toggle={onClose} 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>{errorMessage}</p>
<p>You can still use {pasteShortcutText} to paste.</p>
</ModalBody>
<ModalFooter>
<Button kind="primary" onClick={handleClose}>
<Button kind="primary" onClick={onClose}>
Dismiss
</Button>
</ModalFooter>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ import {
import Log from '@deephaven/log';
import type { DebouncedFunc } from 'lodash';
import {
ClipboardPermissionsDeniedError,
ClipboardUnavailableError,
TextUtils,
assertNotEmpty,
assertNotNaN,
Expand Down Expand Up @@ -483,12 +485,22 @@ class IrisGridContextMenuHandler extends GridMouseHandler {
group: IrisGridContextMenuHandler.GROUP_COPY,
order: 50,
action: async () => {
const text = await readFromClipboard();
if (text !== null) {
try {
const text = await readFromClipboard();
const items = text.split('\n').map(row => row.split('\t'));
ericlln marked this conversation as resolved.
Show resolved Hide resolved
await grid.pasteValue(items);
} else {
irisGrid.handleOpenNoPastePermissionModal();
} 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;
}
}
},
});
Expand Down
40 changes: 26 additions & 14 deletions packages/utils/src/ClipboardUtils.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { copyToClipboard, readFromClipboard } from './ClipboardUtils';
import {
ClipboardPermissionsDeniedError,
ClipboardUnavailableError,
} from './errors';
import { checkPermission } from './PermissionUtils';

document.execCommand = jest.fn();
Expand Down Expand Up @@ -64,21 +68,25 @@ describe('Clipboard', () => {
describe('readFromClipboard', () => {
beforeEach(() => jest.resetAllMocks());

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

await expect(readFromClipboard()).resolves.toBeNull();
await expect(readFromClipboard()).rejects.toThrow(
ClipboardUnavailableError
);
});

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

await expect(readFromClipboard()).resolves.toBeNull();
await expect(readFromClipboard()).rejects.toThrow(
ClipboardUnavailableError
);
});

it('should return text if permission is granted', async () => {
it('should return text if PermissionState is granted', async () => {
Object.assign(navigator, {
clipboard: {
readText: jest.fn().mockResolvedValueOnce('text from clipboard'),
Expand All @@ -90,7 +98,7 @@ describe('Clipboard', () => {
await expect(readFromClipboard()).resolves.toBe('text from clipboard');
});

it('should return null if permission is denied', async () => {
it('should throw denied error if PermissionState is denied', async () => {
Object.assign(navigator, {
clipboard: {
readText: jest.fn(),
Expand All @@ -99,7 +107,9 @@ describe('Clipboard', () => {

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

await expect(readFromClipboard()).resolves.toBeNull();
await expect(readFromClipboard()).rejects.toThrow(
ClipboardPermissionsDeniedError
);
});

it('should return text if permission prompt accepted', async () => {
Expand All @@ -123,7 +133,7 @@ describe('Clipboard', () => {
expect(mockClipboard.readText).toHaveBeenCalledTimes(2);
});

it('should return null if permission prompt denied', async () => {
it('should throw denied error if permission prompt denied', async () => {
const mockClipboard = {
readText: jest
.fn()
Expand All @@ -138,16 +148,16 @@ describe('Clipboard', () => {
.mockResolvedValueOnce('prompt')
.mockResolvedValue('denied');

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

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

Object.assign(navigator, {
Expand All @@ -156,7 +166,9 @@ describe('Clipboard', () => {

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

await expect(readFromClipboard()).resolves.toBeNull();
await expect(readFromClipboard()).rejects.toThrow(
ClipboardPermissionsDeniedError
);
expect(checkPermission).toHaveBeenCalledTimes(2);
expect(mockClipboard.readText).toHaveBeenCalledTimes(1);
});
Expand Down
65 changes: 41 additions & 24 deletions packages/utils/src/ClipboardUtils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import {
ClipboardPermissionsDeniedError,
ClipboardUnavailableError,
} from './errors';
import UnsupportedPermissionError from './errors/UnsupportedPermissionError';
import { checkPermission } from './PermissionUtils';

/**
Expand Down Expand Up @@ -43,39 +48,51 @@ export function copyToClipboardExecCommand(text: string): void {

/**
* Reads text from the clipboard.
* @returns Promise that resolves to text from clipboard as string, or null if permissions are not supported/granted.
* @returns Promise that resolves to text from clipboard as string, or rejects 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;
}
export async function readFromClipboard(): Promise<string> {
try {
const { clipboard } = navigator;
if (clipboard === undefined) throw new ClipboardUnavailableError();

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
}
let permissionState = await checkPermission('clipboard-read');

// 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;
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();
}
}

if (permissionState === 'denied') {
throw new ClipboardPermissionsDeniedError();
}
ericlln marked this conversation as resolved.
Show resolved Hide resolved
} catch (err: unknown) {
if (err instanceof UnsupportedPermissionError) {
throw new ClipboardUnavailableError();
}

throw err;
}

return null;
throw new ClipboardUnavailableError();
}
13 changes: 7 additions & 6 deletions packages/utils/src/PermissionUtils.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import UnsupportedPermissionError from './errors/UnsupportedPermissionError';
import { checkPermission } from './PermissionUtils';

describe('checkPermission', () => {
Expand All @@ -12,21 +13,21 @@ describe('checkPermission', () => {
},
});

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

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

const result = await checkPermission('');
expect(result).toBeNull();
await expect(checkPermission('')).rejects.toThrow(
UnsupportedPermissionError
);
});
});
17 changes: 12 additions & 5 deletions packages/utils/src/PermissionUtils.ts
Original file line number Diff line number Diff line change
@@ -1,19 +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 PermissionState on success, null if permission is unsupported by browser
* @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<PermissionState | null> {
): Promise<PermissionState> {
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) {
// Permission is unsupported by browser
return null;
} 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;
}
}
5 changes: 5 additions & 0 deletions packages/utils/src/errors/ClipboardPermissionDeniedError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export class ClipboardPermissionsDeniedError extends Error {
isClipboardPermissionsDeniedError = true;
}

export default ClipboardPermissionsDeniedError;
5 changes: 5 additions & 0 deletions packages/utils/src/errors/ClipboardUnavailableError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export class ClipboardUnavailableError extends Error {
isClipboardUnavailableError = true;
}

export default ClipboardUnavailableError;
5 changes: 5 additions & 0 deletions packages/utils/src/errors/UnsupportedPermissionError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export class UnsupportedPermissionError extends Error {
isPermissionUnsupported = true;
}

export default UnsupportedPermissionError;
2 changes: 2 additions & 0 deletions packages/utils/src/errors/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default as ClipboardPermissionsDeniedError } from './ClipboardPermissionDeniedError';
export { default as ClipboardUnavailableError } from './ClipboardUnavailableError';
1 change: 1 addition & 0 deletions packages/utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Loading