diff --git a/.changeset/clipboard-copy-fallback.md b/.changeset/clipboard-copy-fallback.md new file mode 100644 index 0000000000..317dbac796 --- /dev/null +++ b/.changeset/clipboard-copy-fallback.md @@ -0,0 +1,6 @@ +--- +'@hyperdx/app': patch +--- + +Fall back when the browser Clipboard API is unavailable and show a clear error +if copying still fails. diff --git a/packages/app/src/components/DBRowJsonViewer.tsx b/packages/app/src/components/DBRowJsonViewer.tsx index f1eac9fa43..65abd56115 100644 --- a/packages/app/src/components/DBRowJsonViewer.tsx +++ b/packages/app/src/components/DBRowJsonViewer.tsx @@ -29,6 +29,10 @@ import { import HyperJson, { GetLineActions, LineAction } from '@/components/HyperJson'; import { mergePath } from '@/utils'; +import { + CLIPBOARD_ERROR_MESSAGE, + copyTextToClipboard, +} from '@/utils/clipboard'; type JSONExtractFn = | 'JSONExtractString' @@ -219,12 +223,19 @@ function HyperJsonMenu({ rowData }: { rowData: any }) { {rowData != null && ( { - window.navigator.clipboard.writeText( + onClick={async () => { + const copied = await copyTextToClipboard( typeof rowData === 'string' ? rowData : JSON.stringify(rowData, null, 2), ); + if (!copied) { + notifications.show({ + color: 'red', + message: CLIPBOARD_ERROR_MESSAGE, + }); + return; + } notifications.show({ color: 'green', message: `Value copied to clipboard`, @@ -547,7 +558,7 @@ export function DBRowJsonViewer({ }); } - const handleCopyObject = () => { + const handleCopyObject = async () => { let copiedObj; // When in parsed JSON context (e.g., expanded stringified JSON), @@ -559,9 +570,16 @@ export function DBRowJsonViewer({ copiedObj = keyPath.length === 0 ? rowData : get(rowData, keyPath); } - window.navigator.clipboard.writeText( + const copied = await copyTextToClipboard( JSON.stringify(copiedObj, null, 2), ); + if (!copied) { + notifications.show({ + color: 'red', + message: CLIPBOARD_ERROR_MESSAGE, + }); + return; + } notifications.show({ color: 'green', message: `Copied object to clipboard`, @@ -583,12 +601,19 @@ export function DBRowJsonViewer({ Copy Value ), - onClick: () => { - window.navigator.clipboard.writeText( + onClick: async () => { + const copied = await copyTextToClipboard( typeof value === 'string' ? value : JSON.stringify(value, null, 2), ); + if (!copied) { + notifications.show({ + color: 'red', + message: CLIPBOARD_ERROR_MESSAGE, + }); + return; + } notifications.show({ color: 'green', message: `Value copied to clipboard`, diff --git a/packages/app/src/components/DBTable/DBRowTableFieldWithPopover.tsx b/packages/app/src/components/DBTable/DBRowTableFieldWithPopover.tsx index 0f410db20e..a36fc15f37 100644 --- a/packages/app/src/components/DBTable/DBRowTableFieldWithPopover.tsx +++ b/packages/app/src/components/DBTable/DBRowTableFieldWithPopover.tsx @@ -2,8 +2,14 @@ import React, { useContext, useEffect, useRef, useState } from 'react'; import cx from 'classnames'; import { Popover } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; +import { notifications } from '@mantine/notifications'; import { IconCopy, IconFilter, IconFilterX } from '@tabler/icons-react'; +import { + CLIPBOARD_ERROR_MESSAGE, + copyTextToClipboard, +} from '@/utils/clipboard'; + import { RowSidePanelContext } from '../DBRowSidePanel'; import { DBRowTableIconButton } from './DBRowTableIconButton'; @@ -83,16 +89,18 @@ const DBRowTableFieldWithPopover = ({ }; const copyFieldValue = async () => { - try { - const value = - typeof cellValue === 'string' ? cellValue : String(cellValue ?? ''); - await navigator.clipboard.writeText(value); - setIsCopied(true); - setTimeout(() => setIsCopied(false), 2000); - } catch (error) { - console.error('Failed to copy to clipboard:', error); - // Optionally show an error toast notification to the user + const value = + typeof cellValue === 'string' ? cellValue : String(cellValue ?? ''); + const copied = await copyTextToClipboard(value); + if (!copied) { + notifications.show({ + color: 'red', + message: CLIPBOARD_ERROR_MESSAGE, + }); + return; } + setIsCopied(true); + setTimeout(() => setIsCopied(false), 2000); }; const addFilter = () => { diff --git a/packages/app/src/components/DBTable/DBRowTableRowButtons.tsx b/packages/app/src/components/DBTable/DBRowTableRowButtons.tsx index 6e0fc23fd2..fb08da39e3 100644 --- a/packages/app/src/components/DBTable/DBRowTableRowButtons.tsx +++ b/packages/app/src/components/DBTable/DBRowTableRowButtons.tsx @@ -1,7 +1,12 @@ import React, { useState } from 'react'; +import { notifications } from '@mantine/notifications'; import { IconCopy, IconLink, IconTextWrap } from '@tabler/icons-react'; import { INTERNAL_ROW_FIELDS, RowWhereResult } from '@/hooks/useRowWhere'; +import { + CLIPBOARD_ERROR_MESSAGE, + copyTextToClipboard, +} from '@/utils/clipboard'; import { DBRowTableIconButton } from './DBRowTableIconButton'; @@ -53,31 +58,42 @@ const DBRowTableRowButtons: React.FC = ({ ); const rowData = JSON.stringify(parsedRow, null, 2); - await navigator.clipboard.writeText(rowData); + const copied = await copyTextToClipboard(rowData); + if (!copied) { + notifications.show({ + color: 'red', + message: CLIPBOARD_ERROR_MESSAGE, + }); + return; + } setIsCopied(true); setTimeout(() => setIsCopied(false), 2000); - } catch (error) { - console.error('Failed to copy row data to clipboard:', error); - // Optionally show an error toast notification to the user + } catch { + notifications.show({ + color: 'red', + message: CLIPBOARD_ERROR_MESSAGE, + }); } }; const copyRowUrl = async () => { - try { - const rowWhereResult = getRowWhere(row); - const currentUrl = new URL(window.location.href); - // Add the row identifier as query parameters - currentUrl.searchParams.set('rowWhere', rowWhereResult.where); - if (sourceId) { - currentUrl.searchParams.set('rowSource', sourceId); - } - await navigator.clipboard.writeText(currentUrl.toString()); - setIsUrlCopied(true); - setTimeout(() => setIsUrlCopied(false), 2000); - } catch (error) { - console.error('Failed to copy URL to clipboard:', error); - // Optionally show an error toast notification to the user + const rowWhereResult = getRowWhere(row); + const currentUrl = new URL(window.location.href); + // Add the row identifier as query parameters + currentUrl.searchParams.set('rowWhere', rowWhereResult.where); + if (sourceId) { + currentUrl.searchParams.set('rowSource', sourceId); + } + const copied = await copyTextToClipboard(currentUrl.toString()); + if (!copied) { + notifications.show({ + color: 'red', + message: CLIPBOARD_ERROR_MESSAGE, + }); + return; } + setIsUrlCopied(true); + setTimeout(() => setIsUrlCopied(false), 2000); }; return ( diff --git a/packages/app/src/utils/__tests__/clipboard.test.ts b/packages/app/src/utils/__tests__/clipboard.test.ts new file mode 100644 index 0000000000..a9fba724f9 --- /dev/null +++ b/packages/app/src/utils/__tests__/clipboard.test.ts @@ -0,0 +1,131 @@ +import { copyTextToClipboard } from '../clipboard'; + +describe('copyTextToClipboard', () => { + const originalClipboard = navigator.clipboard; + const originalExecCommand = document.execCommand; + + afterEach(() => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: originalClipboard, + }); + document.execCommand = originalExecCommand; + document.body.innerHTML = ''; + jest.restoreAllMocks(); + }); + + it('uses the Clipboard API when it is available', async () => { + const writeText = jest.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + + await expect(copyTextToClipboard('hello')).resolves.toBe(true); + + expect(writeText).toHaveBeenCalledWith('hello'); + }); + + it('falls back to a textarea copy when the Clipboard API is unavailable', async () => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: undefined, + }); + document.execCommand = jest.fn().mockReturnValue(true); + const initialChildCount = document.body.childElementCount; + + await expect(copyTextToClipboard('fallback text')).resolves.toBe(true); + + expect(document.execCommand).toHaveBeenCalledWith('copy'); + expect(document.body.childElementCount).toBe(initialChildCount); + }); + + it('falls back to a textarea copy when the Clipboard API rejects', async () => { + const writeText = jest.fn().mockRejectedValue(new Error('denied')); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + document.execCommand = jest.fn().mockReturnValue(true); + + await expect(copyTextToClipboard('blocked')).resolves.toBe(true); + + expect(writeText).toHaveBeenCalledWith('blocked'); + expect(document.execCommand).toHaveBeenCalledWith('copy'); + }); + + it('removes the fallback textarea when execCommand throws', async () => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: undefined, + }); + document.execCommand = jest.fn(() => { + throw new Error('copy failed'); + }); + const initialChildCount = document.body.childElementCount; + + await expect(copyTextToClipboard('throwing copy')).resolves.toBe(false); + + expect(document.body.childElementCount).toBe(initialChildCount); + }); + + it('restores the previous selection after the textarea fallback', async () => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: undefined, + }); + document.execCommand = jest.fn().mockReturnValue(true); + const selectedText = document.createTextNode('selected text'); + const container = document.createElement('div'); + container.appendChild(selectedText); + document.body.appendChild(container); + const range = document.createRange(); + range.selectNodeContents(selectedText); + const selection = document.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + + await expect(copyTextToClipboard('copy text')).resolves.toBe(true); + + expect(selection?.rangeCount).toBe(1); + expect(selection?.getRangeAt(0).toString()).toBe('selected text'); + }); + + it('still reports the copy result if selection restoration fails', async () => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: undefined, + }); + document.execCommand = jest.fn().mockReturnValue(true); + const selectedText = document.createTextNode('selected text'); + const container = document.createElement('div'); + container.appendChild(selectedText); + document.body.appendChild(container); + const range = document.createRange(); + range.selectNodeContents(selectedText); + const selection = document.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + const addRange = jest + .spyOn(selection!, 'addRange') + .mockImplementation(() => { + throw new Error('range detached'); + }); + + await expect(copyTextToClipboard('copy text')).resolves.toBe(true); + + expect(addRange).toHaveBeenCalled(); + }); + + it('reports failure when both copy methods are unavailable', async () => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: undefined, + }); + document.execCommand = jest.fn().mockReturnValue(false); + + await expect(copyTextToClipboard('nope')).resolves.toBe(false); + + expect(document.execCommand).toHaveBeenCalledWith('copy'); + }); +}); diff --git a/packages/app/src/utils/clipboard.ts b/packages/app/src/utils/clipboard.ts new file mode 100644 index 0000000000..1333823afa --- /dev/null +++ b/packages/app/src/utils/clipboard.ts @@ -0,0 +1,69 @@ +export const CLIPBOARD_ERROR_MESSAGE = + 'Could not access the clipboard. Check browser permissions or use HTTPS.'; + +export async function copyTextToClipboard(text: string): Promise { + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + return copyTextWithTextarea(text); + } + } + + return copyTextWithTextarea(text); +} + +function copyTextWithTextarea(text: string): boolean { + if (typeof document === 'undefined' || !document.body) { + return false; + } + + const textArea = document.createElement('textarea'); + textArea.value = text; + textArea.setAttribute('readonly', ''); + textArea.style.position = 'fixed'; + textArea.style.top = '0'; + textArea.style.left = '0'; + textArea.style.width = '1px'; + textArea.style.height = '1px'; + textArea.style.padding = '0'; + textArea.style.border = 'none'; + textArea.style.outline = 'none'; + textArea.style.boxShadow = 'none'; + textArea.style.background = 'transparent'; + + const selection = document.getSelection(); + const previousRange = + selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null; + const previousActiveElement = + document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + + document.body.appendChild(textArea); + textArea.select(); + textArea.setSelectionRange(0, textArea.value.length); + + let copied = false; + try { + copied = document.execCommand('copy'); + } catch { + copied = false; + } finally { + document.body.removeChild(textArea); + if (previousActiveElement && document.contains(previousActiveElement)) { + previousActiveElement.focus({ preventScroll: true }); + } + if (previousRange && selection) { + try { + selection.removeAllRanges(); + selection.addRange(previousRange); + } catch { + // Ignore restore failures if the previous selection was detached. + } + } + } + + return copied; +}